Files
natsdotnet/tests/NATS.Server.Tests/MsgTraceGoParityTests.cs
Joseph Doherty e190af5289 test(parity): port message trace & infrastructure tests (Task 27, 105 tests)
34 msg trace tests — basic, routed, leaf, gateway, compressed, JetStream
71 infrastructure tests — parser, log, errors, config check, subject transform,
   nkey, ping, util, trust, closed conns, rate counter
Go refs: msgtrace_test.go, closed_conns_test.go, parser_test.go, log_test.go,
         errors_test.go, config_check_test.go, subject_transform_test.go, etc.
2026-02-24 22:05:44 -05:00

756 lines
29 KiB
C#

// 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;
/// <summary>
/// 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.
/// </summary>
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<string> 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<Socket> 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) ──
/// <summary>
/// MessageTraceContext.Empty has all identity fields null and headers disabled.
/// Mirrors the Go zero-value trace context.
/// Go: TestMsgTraceConnName (msgtrace_test.go:40)
/// </summary>
[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();
}
/// <summary>
/// CreateFromConnect with null produces Empty.
/// Go: TestMsgTraceConnName (msgtrace_test.go:40)
/// </summary>
[Fact]
public void MsgTrace_create_from_null_opts_returns_empty()
{
// Go: TestMsgTraceConnName — null opts fallback
var ctx = MessageTraceContext.CreateFromConnect(null);
ctx.ShouldBe(MessageTraceContext.Empty);
}
/// <summary>
/// CreateFromConnect captures name / lang / version / headers from ClientOptions.
/// Go: TestMsgTraceConnName (msgtrace_test.go:40) — client identity on trace event
/// </summary>
[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();
}
/// <summary>
/// Client without headers support produces HeadersEnabled = false.
/// Go: TestMsgTraceBasic (msgtrace_test.go:172)
/// </summary>
[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");
}
/// <summary>
/// MessageTraceContext is a record — value equality by fields.
/// Go: TestMsgTraceConnName (msgtrace_test.go:40)
/// </summary>
[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) ──
/// <summary>
/// NatsHeaderParser correctly parses Nats-Trace-Dest from an HPUB block.
/// Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80)
/// </summary>
[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");
}
/// <summary>
/// NatsHeaderParser returns Invalid when prefix is wrong.
/// Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) — "missing header line"
/// </summary>
[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);
}
/// <summary>
/// No trace headers present → parser returns Invalid / empty map.
/// Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) — "no trace header present"
/// </summary>
[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);
}
/// <summary>
/// Multiple headers including Nats-Trace-Dest are all parsed.
/// Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) — "trace header first"
/// </summary>
[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");
}
/// <summary>
/// Header lookup is case-insensitive.
/// Go: TestMsgTraceGenHeaderMap (msgtrace_test.go:80) — case handling
/// </summary>
[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) ──
/// <summary>
/// Nats-Trace-Dest in an HPUB is delivered verbatim in the HMSG.
/// Go: TestMsgTraceBasic (msgtrace_test.go:172)
/// </summary>
[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");
}
/// <summary>
/// Nats-Trace-Dest header is preserved through a wildcard subscription match.
/// Go: TestMsgTraceBasic (msgtrace_test.go:172) — wildcard delivery
/// </summary>
[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");
}
/// <summary>
/// Nats-Trace-Dest preserved through queue group delivery.
/// Go: TestMsgTraceBasic (msgtrace_test.go:172) — queue group subscriber
/// </summary>
[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");
}
/// <summary>
/// Multiple custom headers alongside Nats-Trace-Dest all arrive intact.
/// Go: TestMsgTraceBasic (msgtrace_test.go:172) — full header block preserved
/// </summary>
[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) ─────────────────────
/// <summary>
/// NatsOptions.Trace is false by default.
/// Go: opts.go — trace=false by default
/// </summary>
[Fact]
public void MsgTrace_server_trace_is_false_by_default()
{
// Go: opts.go default
new NatsOptions().Trace.ShouldBeFalse();
}
/// <summary>
/// NatsOptions.TraceVerbose is false by default.
/// Go: opts.go — trace_verbose=false by default
/// </summary>
[Fact]
public void MsgTrace_server_trace_verbose_is_false_by_default()
{
// Go: opts.go default
new NatsOptions().TraceVerbose.ShouldBeFalse();
}
/// <summary>
/// NatsOptions.MaxTracedMsgLen is 0 by default (unlimited).
/// Go: opts.go — max_traced_msg_len default=0
/// </summary>
[Fact]
public void MsgTrace_max_traced_msg_len_is_zero_by_default()
{
// Go: opts.go default
new NatsOptions().MaxTracedMsgLen.ShouldBe(0);
}
/// <summary>
/// Server with Trace=true starts normally and accepts connections.
/// Go: TestMsgTraceBasic (msgtrace_test.go:172) — server setup
/// </summary>
[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 ────────────────────────────────────────────────
/// <summary>
/// ClientFlagHolder.TraceMode is not set by default.
/// Go: client.go — trace flag starts unset
/// </summary>
[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();
}
/// <summary>
/// SetFlag/ClearFlag round-trips TraceMode correctly.
/// Go: client.go setTraceMode
/// </summary>
[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();
}
/// <summary>
/// TraceMode is independent of other flags.
/// Go: client.go — per-bit flag isolation
/// </summary>
[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) ───────────────────
/// <summary>
/// Server tracks a closed connection in the closed-clients ring buffer.
/// Go: TestClosedConnsAccounting (closed_conns_test.go:46)
/// </summary>
[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();
}
/// <summary>
/// Closed-clients ring buffer is capped at MaxClosedClients.
/// Go: TestClosedConnsAccounting (closed_conns_test.go:46)
/// </summary>
[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();
}
/// <summary>
/// ClosedClient record exposes the Cid and Reason fields populated on close.
/// Go: TestClosedConnsAccounting (closed_conns_test.go:46)
/// </summary>
[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");
}
/// <summary>
/// MaxClosedClients defaults to 10_000 in NatsOptions.
/// Go: server.go — MaxClosedClients default
/// </summary>
[Fact]
public void ClosedConns_max_closed_clients_default_is_10000()
{
// Go: server.go default MaxClosedClients = 10000
new NatsOptions().MaxClosedClients.ShouldBe(10_000);
}
/// <summary>
/// Connection closed due to MaxPayload exceeded is tracked with correct reason.
/// Go: TestClosedMaxPayload (closed_conns_test.go:219)
/// </summary>
[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();
}
/// <summary>
/// Auth timeout connection is tracked with reason containing "Authentication Timeout".
/// Go: TestClosedAuthorizationTimeout (closed_conns_test.go:143)
/// </summary>
[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();
}
/// <summary>
/// Auth violation connection (wrong token) is tracked with reason containing "Authorization".
/// Go: TestClosedAuthorizationViolation (closed_conns_test.go:164)
/// </summary>
[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) ───────────────
/// <summary>
/// ClosedState enum contains at least the core close reasons checked by Go tests.
/// Go: closed_conns_test.go:136 — checkReason helper
/// </summary>
[Fact]
public void ClosedState_contains_expected_values()
{
// Go: closed_conns_test.go:136 checkReason — AuthenticationTimeout, AuthenticationViolation,
// MaxPayloadExceeded, TLSHandshakeError
var values = Enum.GetValues<ClosedState>();
values.ShouldContain(ClosedState.AuthenticationTimeout);
values.ShouldContain(ClosedState.AuthenticationViolation);
values.ShouldContain(ClosedState.MaxPayloadExceeded);
values.ShouldContain(ClosedState.TLSHandshakeError);
values.ShouldContain(ClosedState.ClientClosed);
}
/// <summary>
/// ClientClosedReason.ToReasonString returns expected human-readable strings.
/// Go: closed_conns_test.go:136 — checkReason, conns[0].Reason
/// </summary>
[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);
}
}