// Go reference: golang/nats-server/server/msgtrace_test.go // Go reference: golang/nats-server/server/closed_conns_test.go // // Coverage: // Message trace infrastructure — header map generation, connection naming, // trace context, header propagation (HPUB/HMSG), server options. // Closed connection tracking — ring-buffer accounting, max limit, subs count, // auth timeout/violation, max-payload close reason. using System.Net; using System.Net.Sockets; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using NATS.Server; using NATS.Server.Monitoring; using NATS.Server.Protocol; namespace NATS.Server.Tests; /// /// Go parity tests for message trace header infrastructure and closed-connection /// tracking. Full $SYS.TRACE event emission is not yet wired end-to-end; these /// tests validate the foundational pieces that must be correct first. /// public class MsgTraceGoParityTests : IAsyncLifetime { private NatsServer _server = null!; private int _port; private CancellationTokenSource _cts = new(); public async Task InitializeAsync() { _port = GetFreePort(); _server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance); _ = _server.StartAsync(_cts.Token); await _server.WaitForReadyAsync(); } public async Task DisposeAsync() { await _cts.CancelAsync(); _server.Dispose(); } // ─── helpers ──────────────────────────────────────────────────────────── 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(); } private async Task ConnectClientAsync(bool headers = true) { var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await sock.ConnectAsync(IPAddress.Loopback, _port); await ReadUntilAsync(sock, "\r\n"); // consume INFO var connectJson = headers ? "{\"verbose\":false,\"headers\":true}" : "{\"verbose\":false}"; await sock.SendAsync(Encoding.ASCII.GetBytes($"CONNECT {connectJson}\r\n")); return sock; } // ─── message trace: connection naming (msgtrace_test.go:TestMsgTraceConnName) ── /// /// MessageTraceContext.Empty has all identity fields null and headers disabled. /// Mirrors the Go zero-value trace context. /// Go: TestMsgTraceConnName (msgtrace_test.go:40) /// [Fact] public void MsgTrace_empty_context_has_null_fields() { // Go: TestMsgTraceConnName — zero-value context var ctx = MessageTraceContext.Empty; ctx.ClientName.ShouldBeNull(); ctx.ClientLang.ShouldBeNull(); ctx.ClientVersion.ShouldBeNull(); ctx.HeadersEnabled.ShouldBeFalse(); } /// /// CreateFromConnect with null produces Empty. /// Go: TestMsgTraceConnName (msgtrace_test.go:40) /// [Fact] public void MsgTrace_create_from_null_opts_returns_empty() { // Go: TestMsgTraceConnName — null opts fallback var ctx = MessageTraceContext.CreateFromConnect(null); ctx.ShouldBe(MessageTraceContext.Empty); } /// /// CreateFromConnect captures name / lang / version / headers from ClientOptions. /// Go: TestMsgTraceConnName (msgtrace_test.go:40) — client identity on trace event /// [Fact] public void MsgTrace_create_from_connect_captures_identity() { // Go: TestMsgTraceConnName (msgtrace_test.go:40) var opts = new ClientOptions { Name = "my-tracer", Lang = "nats.go", Version = "1.30.0", Headers = true, }; var ctx = MessageTraceContext.CreateFromConnect(opts); ctx.ClientName.ShouldBe("my-tracer"); ctx.ClientLang.ShouldBe("nats.go"); ctx.ClientVersion.ShouldBe("1.30.0"); ctx.HeadersEnabled.ShouldBeTrue(); } /// /// Client without headers support produces HeadersEnabled = false. /// Go: TestMsgTraceBasic (msgtrace_test.go:172) /// [Fact] public void MsgTrace_headers_disabled_when_connect_opts_headers_false() { // Go: TestMsgTraceBasic (msgtrace_test.go:172) var opts = new ClientOptions { Name = "legacy", Headers = false }; var ctx = MessageTraceContext.CreateFromConnect(opts); ctx.HeadersEnabled.ShouldBeFalse(); ctx.ClientName.ShouldBe("legacy"); } /// /// MessageTraceContext is a record — value equality by fields. /// Go: TestMsgTraceConnName (msgtrace_test.go:40) /// [Fact] public void MsgTrace_context_record_equality() { // Go: TestMsgTraceConnName (msgtrace_test.go:40) — deterministic identity var a = new MessageTraceContext("app", "nats.go", "1.0", true); var b = new MessageTraceContext("app", "nats.go", "1.0", true); a.ShouldBe(b); a.GetHashCode().ShouldBe(b.GetHashCode()); } // ─── GenHeaderMap — trace header parsing (msgtrace_test.go:TestMsgTraceGenHeaderMap) ── /// /// NatsHeaderParser correctly parses Nats-Trace-Dest from an HPUB block. /// Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) /// [Fact] public void MsgTrace_header_parser_parses_trace_dest_header() { // Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) — "trace header first" const string raw = "NATS/1.0\r\nNats-Trace-Dest: trace.inbox\r\n\r\n"; var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(raw)); headers.ShouldNotBe(NatsHeaders.Invalid); headers.Headers.ContainsKey("Nats-Trace-Dest").ShouldBeTrue(); headers.Headers["Nats-Trace-Dest"].ShouldContain("trace.inbox"); } /// /// NatsHeaderParser returns Invalid when prefix is wrong. /// Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) — "missing header line" /// [Fact] public void MsgTrace_header_parser_returns_invalid_for_bad_prefix() { // Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) — "missing header line" var headers = NatsHeaderParser.Parse("Nats-Trace-Dest: val\r\n"u8.ToArray()); headers.ShouldBe(NatsHeaders.Invalid); } /// /// No trace headers present → parser returns Invalid / empty map. /// Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) — "no trace header present" /// [Fact] public void MsgTrace_header_parser_parses_empty_nats_header_block() { // Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) — empty block var headers = NatsHeaderParser.Parse("NATS/1.0\r\n\r\n"u8.ToArray()); headers.ShouldNotBe(NatsHeaders.Invalid); headers.Headers.Count.ShouldBe(0); } /// /// Multiple headers including Nats-Trace-Dest are all parsed. /// Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) — "trace header first" /// [Fact] public void MsgTrace_header_parser_parses_multiple_headers_with_trace_dest() { // Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) — "trace header first" const string raw = "NATS/1.0\r\n" + "X-App-Id: 42\r\n" + "Nats-Trace-Dest: my.trace.inbox\r\n" + "X-Correlation: abc\r\n" + "\r\n"; var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(raw)); headers.Headers.Count.ShouldBe(3); headers.Headers["Nats-Trace-Dest"].ShouldContain("my.trace.inbox"); headers.Headers["X-App-Id"].ShouldContain("42"); headers.Headers["X-Correlation"].ShouldContain("abc"); } /// /// Header lookup is case-insensitive. /// Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) — case handling /// [Fact] public void MsgTrace_header_lookup_is_case_insensitive() { // Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) const string raw = "NATS/1.0\r\nNats-Trace-Dest: inbox.trace\r\n\r\n"; var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(raw)); headers.Headers.ContainsKey("nats-trace-dest").ShouldBeTrue(); headers.Headers.ContainsKey("NATS-TRACE-DEST").ShouldBeTrue(); headers.Headers["nats-trace-dest"][0].ShouldBe("inbox.trace"); } // ─── wire-level Nats-Trace-Dest header propagation (msgtrace_test.go:TestMsgTraceBasic) ── /// /// Nats-Trace-Dest in an HPUB is delivered verbatim in the HMSG. /// Go: TestMsgTraceBasic (msgtrace_test.go:172) /// [Fact] public async Task MsgTrace_hpub_trace_dest_header_delivered_verbatim() { // Go: TestMsgTraceBasic (msgtrace_test.go:172) — header pass-through using var sub = await ConnectClientAsync(); using var pub = await ConnectClientAsync(); await sub.SendAsync("SUB trace.test 1\r\nPING\r\n"u8.ToArray()); await ReadUntilAsync(sub, "PONG"); const string hdrBlock = "NATS/1.0\r\nNats-Trace-Dest: trace.inbox\r\n\r\n"; const string payload = "hello"; int hdrLen = Encoding.ASCII.GetByteCount(hdrBlock); int totalLen = hdrLen + Encoding.ASCII.GetByteCount(payload); await pub.SendAsync(Encoding.ASCII.GetBytes( $"HPUB trace.test {hdrLen} {totalLen}\r\n{hdrBlock}{payload}\r\n")); var received = await ReadUntilAsync(sub, "Nats-Trace-Dest"); received.ShouldContain("HMSG trace.test"); received.ShouldContain("Nats-Trace-Dest: trace.inbox"); received.ShouldContain("hello"); } /// /// Nats-Trace-Dest header is preserved through a wildcard subscription match. /// Go: TestMsgTraceBasic (msgtrace_test.go:172) — wildcard delivery /// [Fact] public async Task MsgTrace_hpub_trace_dest_preserved_through_wildcard() { // Go: TestMsgTraceBasic (msgtrace_test.go:172) — wildcard subscriber using var sub = await ConnectClientAsync(); using var pub = await ConnectClientAsync(); await sub.SendAsync("SUB trace.* 1\r\nPING\r\n"u8.ToArray()); await ReadUntilAsync(sub, "PONG"); const string hdrBlock = "NATS/1.0\r\nNats-Trace-Dest: t.inbox.1\r\n\r\n"; const string payload = "wildcard-msg"; int hdrLen = Encoding.ASCII.GetByteCount(hdrBlock); int totalLen = hdrLen + Encoding.ASCII.GetByteCount(payload); await pub.SendAsync(Encoding.ASCII.GetBytes( $"HPUB trace.subject {hdrLen} {totalLen}\r\n{hdrBlock}{payload}\r\n")); var received = await ReadUntilAsync(sub, "Nats-Trace-Dest"); received.ShouldContain("HMSG trace.subject"); received.ShouldContain("Nats-Trace-Dest: t.inbox.1"); received.ShouldContain("wildcard-msg"); } /// /// Nats-Trace-Dest preserved through queue group delivery. /// Go: TestMsgTraceBasic (msgtrace_test.go:172) — queue group subscriber /// [Fact] public async Task MsgTrace_hpub_trace_dest_preserved_through_queue_group() { // Go: TestMsgTraceBasic (msgtrace_test.go:172) — queue-group delivery using var qsub = await ConnectClientAsync(); using var pub = await ConnectClientAsync(); // Subscribe via a queue group await qsub.SendAsync("SUB trace.q workers 1\r\nPING\r\n"u8.ToArray()); await ReadUntilAsync(qsub, "PONG"); const string hdrBlock = "NATS/1.0\r\nNats-Trace-Dest: qg.trace\r\n\r\n"; const string payload = "queued"; int hdrLen = Encoding.ASCII.GetByteCount(hdrBlock); int totalLen = hdrLen + Encoding.ASCII.GetByteCount(payload); // Publish from a separate connection await pub.SendAsync(Encoding.ASCII.GetBytes( $"HPUB trace.q {hdrLen} {totalLen}\r\n{hdrBlock}{payload}\r\n")); var received = await ReadUntilAsync(qsub, "Nats-Trace-Dest", 3000); received.ShouldContain("Nats-Trace-Dest: qg.trace"); received.ShouldContain("queued"); } /// /// Multiple custom headers alongside Nats-Trace-Dest all arrive intact. /// Go: TestMsgTraceBasic (msgtrace_test.go:172) — full header block preserved /// [Fact] public async Task MsgTrace_hpub_multiple_headers_with_trace_dest_all_delivered_intact() { // Go: TestMsgTraceBasic (msgtrace_test.go:172) — multi-header block using var sub = await ConnectClientAsync(); using var pub = await ConnectClientAsync(); await sub.SendAsync("SUB multi.hdr 1\r\nPING\r\n"u8.ToArray()); await ReadUntilAsync(sub, "PONG"); const string hdrBlock = "NATS/1.0\r\n" + "X-Request-Id: req-99\r\n" + "Nats-Trace-Dest: t.multi\r\n" + "X-Priority: high\r\n" + "\r\n"; const string payload = "multi-hdr-payload"; int hdrLen = Encoding.ASCII.GetByteCount(hdrBlock); int totalLen = hdrLen + Encoding.ASCII.GetByteCount(payload); await pub.SendAsync(Encoding.ASCII.GetBytes( $"HPUB multi.hdr {hdrLen} {totalLen}\r\n{hdrBlock}{payload}\r\n")); var received = await ReadUntilAsync(sub, "X-Priority"); received.ShouldContain("X-Request-Id: req-99"); received.ShouldContain("Nats-Trace-Dest: t.multi"); received.ShouldContain("X-Priority: high"); received.ShouldContain("multi-hdr-payload"); } // ─── server trace options (msgtrace_test.go/opts.go) ───────────────────── /// /// NatsOptions.Trace is false by default. /// Go: opts.go — trace=false by default /// [Fact] public void MsgTrace_server_trace_is_false_by_default() { // Go: opts.go default new NatsOptions().Trace.ShouldBeFalse(); } /// /// NatsOptions.TraceVerbose is false by default. /// Go: opts.go — trace_verbose=false by default /// [Fact] public void MsgTrace_server_trace_verbose_is_false_by_default() { // Go: opts.go default new NatsOptions().TraceVerbose.ShouldBeFalse(); } /// /// NatsOptions.MaxTracedMsgLen is 0 by default (unlimited). /// Go: opts.go — max_traced_msg_len default=0 /// [Fact] public void MsgTrace_max_traced_msg_len_is_zero_by_default() { // Go: opts.go default new NatsOptions().MaxTracedMsgLen.ShouldBe(0); } /// /// Server with Trace=true starts normally and accepts connections. /// Go: TestMsgTraceBasic (msgtrace_test.go:172) — server setup /// [Fact] public async Task MsgTrace_server_with_trace_enabled_starts_and_accepts_connections() { // Go: TestMsgTraceBasic (msgtrace_test.go:172) var port = GetFreePort(); using var cts = new CancellationTokenSource(); using var server = new NatsServer( new NatsOptions { Port = port, Trace = true }, NullLoggerFactory.Instance); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await sock.ConnectAsync(IPAddress.Loopback, port); var info = await ReadUntilAsync(sock, "\r\n"); info.ShouldStartWith("INFO "); await cts.CancelAsync(); } // ─── ClientFlags.TraceMode ──────────────────────────────────────────────── /// /// ClientFlagHolder.TraceMode is not set by default. /// Go: client.go — trace flag starts unset /// [Fact] public void MsgTrace_client_flag_trace_mode_unset_by_default() { // Go: client.go — clientFlag trace bit var holder = new ClientFlagHolder(); holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse(); } /// /// SetFlag/ClearFlag round-trips TraceMode correctly. /// Go: client.go setTraceMode /// [Fact] public void MsgTrace_client_flag_trace_mode_set_and_clear() { // Go: client.go setTraceMode var holder = new ClientFlagHolder(); holder.SetFlag(ClientFlags.TraceMode); holder.HasFlag(ClientFlags.TraceMode).ShouldBeTrue(); holder.ClearFlag(ClientFlags.TraceMode); holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse(); } /// /// TraceMode is independent of other flags. /// Go: client.go — per-bit flag isolation /// [Fact] public void MsgTrace_client_flag_trace_mode_does_not_affect_other_flags() { // Go: client.go — per-bit flag isolation var holder = new ClientFlagHolder(); holder.SetFlag(ClientFlags.ConnectReceived); holder.SetFlag(ClientFlags.FirstPongSent); holder.SetFlag(ClientFlags.TraceMode); holder.ClearFlag(ClientFlags.TraceMode); holder.HasFlag(ClientFlags.ConnectReceived).ShouldBeTrue(); holder.HasFlag(ClientFlags.FirstPongSent).ShouldBeTrue(); holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse(); } // ─── closed connection tracking (closed_conns_test.go) ─────────────────── /// /// Server tracks a closed connection in the closed-clients ring buffer. /// Go: TestClosedConnsAccounting (closed_conns_test.go:46) /// [Fact] public async Task ClosedConns_accounting_tracks_one_closed_client() { // Go: TestClosedConnsAccounting (closed_conns_test.go:46) using var sock = await ConnectClientAsync(); // Do a full handshake so the client is accepted await sock.SendAsync("PING\r\n"u8.ToArray()); await ReadUntilAsync(sock, "PONG"); // Close the connection sock.Close(); // Wait for the server to register the close var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); while (DateTime.UtcNow < deadline) { if (_server.GetClosedClients().Any()) break; await Task.Delay(10); } _server.GetClosedClients().ShouldNotBeEmpty(); } /// /// Closed-clients ring buffer is capped at MaxClosedClients. /// Go: TestClosedConnsAccounting (closed_conns_test.go:46) /// [Fact] public async Task ClosedConns_ring_buffer_bounded_by_max_closed_clients() { // Go: TestClosedConnsAccounting (closed_conns_test.go:46) // Build a server with a tiny ring buffer var port = GetFreePort(); using var cts = new CancellationTokenSource(); using var server = new NatsServer( new NatsOptions { Port = port, MaxClosedClients = 5 }, NullLoggerFactory.Instance); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); // Open and close 10 connections for (int i = 0; i < 10; i++) { using var s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await s.ConnectAsync(IPAddress.Loopback, port); await ReadUntilAsync(s, "\r\n"); // INFO await s.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nPING\r\n")); await ReadUntilAsync(s, "PONG"); s.Close(); // brief pause to let server process await Task.Delay(5); } // Allow processing await Task.Delay(200); var closed = server.GetClosedClients().ToList(); closed.Count.ShouldBeLessThanOrEqualTo(5); await cts.CancelAsync(); } /// /// ClosedClient record exposes the Cid and Reason fields populated on close. /// Go: TestClosedConnsAccounting (closed_conns_test.go:46) /// [Fact] public void ClosedConns_record_has_cid_and_reason_fields() { // Go: TestClosedConnsAccounting (closed_conns_test.go:46) — ClosedClient fields var cc = new ClosedClient { Cid = 42, Reason = "Client Closed", }; cc.Cid.ShouldBe(42UL); cc.Reason.ShouldBe("Client Closed"); } /// /// MaxClosedClients defaults to 10_000 in NatsOptions. /// Go: server.go — MaxClosedClients default /// [Fact] public void ClosedConns_max_closed_clients_default_is_10000() { // Go: server.go default MaxClosedClients = 10000 new NatsOptions().MaxClosedClients.ShouldBe(10_000); } /// /// Connection closed due to MaxPayload exceeded is tracked with correct reason. /// Go: TestClosedMaxPayload (closed_conns_test.go:219) /// [Fact] public async Task ClosedConns_max_payload_close_reason_tracked() { // Go: TestClosedMaxPayload (closed_conns_test.go:219) var port = GetFreePort(); using var cts = new CancellationTokenSource(); using var server = new NatsServer( new NatsOptions { Port = port, MaxPayload = 100 }, NullLoggerFactory.Instance); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); var conn = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await conn.ConnectAsync(IPAddress.Loopback, port); await ReadUntilAsync(conn, "\r\n"); // INFO // Establish connection first await conn.SendAsync("CONNECT {\"verbose\":false}\r\nPING\r\n"u8.ToArray()); await ReadUntilAsync(conn, "PONG"); // Send a PUB with payload > MaxPayload (200 bytes > 100 byte limit) // Must include the full payload so the parser yields the command to NatsClient var bigPayload = new byte[200]; var pubLine = $"PUB foo.bar {bigPayload.Length}\r\n"; var fullMsg = Encoding.ASCII.GetBytes(pubLine).Concat(bigPayload).Concat("\r\n"u8.ToArray()).ToArray(); await conn.SendAsync(fullMsg); // Wait for server to close and record it var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); while (DateTime.UtcNow < deadline) { if (server.GetClosedClients().Any()) break; await Task.Delay(10); } conn.Dispose(); var conns = server.GetClosedClients().ToList(); conns.Count.ShouldBeGreaterThan(0); // The reason should indicate max-payload exceeded conns.Any(c => c.Reason.Contains("Maximum Payload", StringComparison.OrdinalIgnoreCase) || c.Reason.Contains("Payload", StringComparison.OrdinalIgnoreCase)) .ShouldBeTrue(); await cts.CancelAsync(); } /// /// Auth timeout connection is tracked with reason containing "Authentication Timeout". /// Go: TestClosedAuthorizationTimeout (closed_conns_test.go:143) /// [Fact] public async Task ClosedConns_auth_timeout_close_reason_tracked() { // Go: TestClosedAuthorizationTimeout (closed_conns_test.go:143) var port = GetFreePort(); using var cts = new CancellationTokenSource(); using var server = new NatsServer( new NatsOptions { Port = port, Authorization = "required_token", AuthTimeout = TimeSpan.FromMilliseconds(200), }, NullLoggerFactory.Instance); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); // Just connect without sending CONNECT — auth timeout fires var conn = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await conn.ConnectAsync(IPAddress.Loopback, port); await ReadUntilAsync(conn, "\r\n"); // INFO // Don't send CONNECT — wait for auth timeout var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); while (DateTime.UtcNow < deadline) { if (server.GetClosedClients().Any()) break; await Task.Delay(10); } conn.Dispose(); var conns = server.GetClosedClients().ToList(); conns.Count.ShouldBeGreaterThan(0); conns.Any(c => c.Reason.Contains("Authentication Timeout", StringComparison.OrdinalIgnoreCase)) .ShouldBeTrue(); await cts.CancelAsync(); } /// /// Auth violation connection (wrong token) is tracked with reason containing "Authorization". /// Go: TestClosedAuthorizationViolation (closed_conns_test.go:164) /// [Fact] public async Task ClosedConns_auth_violation_close_reason_tracked() { // Go: TestClosedAuthorizationViolation (closed_conns_test.go:164) var port = GetFreePort(); using var cts = new CancellationTokenSource(); using var server = new NatsServer( new NatsOptions { Port = port, Authorization = "correct_token" }, NullLoggerFactory.Instance); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); // Connect with wrong token var conn = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await conn.ConnectAsync(IPAddress.Loopback, port); await ReadUntilAsync(conn, "\r\n"); // INFO await conn.SendAsync( "CONNECT {\"verbose\":false,\"auth_token\":\"wrong_token\"}\r\nPING\r\n"u8.ToArray()); // Wait for close and error response await ReadUntilAsync(conn, "-ERR", 2000); conn.Dispose(); var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); while (DateTime.UtcNow < deadline) { if (server.GetClosedClients().Any()) break; await Task.Delay(10); } var conns = server.GetClosedClients().ToList(); conns.Count.ShouldBeGreaterThan(0); conns.Any(c => c.Reason.Contains("Authorization", StringComparison.OrdinalIgnoreCase) || c.Reason.Contains("Authentication", StringComparison.OrdinalIgnoreCase)) .ShouldBeTrue(); await cts.CancelAsync(); } // ─── ClosedState enum (closed_conns_test.go — checkReason) ─────────────── /// /// ClosedState enum contains at least the core close reasons checked by Go tests. /// Go: closed_conns_test.go:136 — checkReason helper /// [Fact] public void ClosedState_contains_expected_values() { // Go: closed_conns_test.go:136 checkReason — AuthenticationTimeout, AuthenticationViolation, // MaxPayloadExceeded, TLSHandshakeError var values = Enum.GetValues(); values.ShouldContain(ClosedState.AuthenticationTimeout); values.ShouldContain(ClosedState.AuthenticationViolation); values.ShouldContain(ClosedState.MaxPayloadExceeded); values.ShouldContain(ClosedState.TLSHandshakeError); values.ShouldContain(ClosedState.ClientClosed); } /// /// ClientClosedReason.ToReasonString returns expected human-readable strings. /// Go: closed_conns_test.go:136 — checkReason, conns[0].Reason /// [Theory] [InlineData(ClientClosedReason.ClientClosed, "Client Closed")] [InlineData(ClientClosedReason.AuthenticationTimeout, "Authentication Timeout")] [InlineData(ClientClosedReason.MaxPayloadExceeded, "Maximum Payload Exceeded")] [InlineData(ClientClosedReason.StaleConnection, "Stale Connection")] [InlineData(ClientClosedReason.ServerShutdown, "Server Shutdown")] public void ClosedState_reason_string_contains_human_readable_text( ClientClosedReason reason, string expectedSubstring) { // Go: closed_conns_test.go:136 — checkReason reason.ToReasonString().ShouldContain(expectedSubstring); } }