diff --git a/tests/NATS.Server.Tests/InfrastructureGoParityTests.cs b/tests/NATS.Server.Tests/InfrastructureGoParityTests.cs
new file mode 100644
index 0000000..851c693
--- /dev/null
+++ b/tests/NATS.Server.Tests/InfrastructureGoParityTests.cs
@@ -0,0 +1,1107 @@
+// Go reference: golang/nats-server/server/parser_test.go
+// Go reference: golang/nats-server/server/log_test.go
+// Go reference: golang/nats-server/server/errors_test.go
+// Go reference: golang/nats-server/server/config_check_test.go
+// Go reference: golang/nats-server/server/subject_transform_test.go
+// Go reference: golang/nats-server/server/nkey_test.go
+// Go reference: golang/nats-server/server/ping_test.go
+// Go reference: golang/nats-server/server/util_test.go
+// Go reference: golang/nats-server/server/trust_test.go
+//
+// Coverage:
+// Parser unit tests — ParseSize, HPUB, PUB, SUB, PING/PONG, CONNECT, proto snippet.
+// Logging — Serilog file sink, log-reopen semantics, secrets redaction.
+// Errors — error context wrapping, ErrorIs through context chain.
+// Config check — unknown fields, validation errors, ConfigProcessorException.
+// Subject transforms — basic transforms, partition, split, slice, error cases.
+// NKey auth — nonceRequired, nonce generation, AuthService with NKeys.
+// Ping — server sends periodic PING, client PONG keeps connection alive.
+// Util — ParseSize, version checks, URL redaction (ported as .NET equivalent).
+// Trust — TrustedKeys options validation.
+
+using System.Buffers;
+using System.IO.Pipelines;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Logging.Abstractions;
+using NATS.NKeys;
+using NATS.Server;
+using NATS.Server.Auth;
+using NATS.Server.Configuration;
+using NATS.Server.Protocol;
+using NATS.Server.Subscriptions;
+using Serilog;
+
+namespace NATS.Server.Tests;
+
+///
+/// Infrastructure parity tests covering parser utilities, logging, error wrapping,
+/// config validation, subject transforms, NKey auth, ping, utility helpers, and trust keys.
+///
+public class InfrastructureGoParityTests
+{
+ // ─── 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 static async Task> ParseCommandsAsync(string input)
+ {
+ var pipe = new Pipe();
+ var commands = new List();
+ var bytes = Encoding.ASCII.GetBytes(input);
+ await pipe.Writer.WriteAsync(bytes);
+ pipe.Writer.Complete();
+
+ var parser = new NatsParser(maxPayload: NatsProtocol.MaxPayloadSize);
+ while (true)
+ {
+ var result = await pipe.Reader.ReadAsync();
+ var buffer = result.Buffer;
+ while (parser.TryParse(ref buffer, out var cmd))
+ commands.Add(cmd);
+ pipe.Reader.AdvanceTo(buffer.Start, buffer.End);
+ if (result.IsCompleted) break;
+ }
+ return commands;
+ }
+
+ // ─── Parser: ParseSize (util_test.go:TestParseSize) ──────────────────────
+
+ ///
+ /// ParseSize returns -1 for an empty span.
+ /// Go: TestParseSize (util_test.go:27) — nil byte slice returns -1
+ ///
+ [Fact]
+ public void Parser_ParseSize_returns_minus1_for_empty()
+ {
+ // Go: TestParseSize (util_test.go:27)
+ NatsParser.ParseSize(Span.Empty).ShouldBe(-1);
+ }
+
+ ///
+ /// ParseSize correctly parses a valid decimal integer.
+ /// Go: TestParseSize (util_test.go:27)
+ ///
+ [Fact]
+ public void Parser_ParseSize_parses_valid_decimal()
+ {
+ // Go: TestParseSize (util_test.go:27)
+ NatsParser.ParseSize("12345678"u8.ToArray().AsSpan()).ShouldBe(12345678);
+ }
+
+ ///
+ /// ParseSize returns -1 for invalid (non-digit) bytes.
+ /// Go: TestParseSize (util_test.go:27)
+ ///
+ [Fact]
+ public void Parser_ParseSize_returns_minus1_for_invalid_bytes()
+ {
+ // Go: TestParseSize (util_test.go:27)
+ NatsParser.ParseSize("12345invalid678"u8.ToArray().AsSpan()).ShouldBe(-1);
+ }
+
+ ///
+ /// ParseSize parses single digit.
+ /// Go: TestParseSize (util_test.go:27)
+ ///
+ [Fact]
+ public void Parser_ParseSize_parses_single_digit()
+ {
+ // Go: TestParseSize (util_test.go:27)
+ NatsParser.ParseSize("5"u8.ToArray().AsSpan()).ShouldBe(5);
+ }
+
+ // ─── Parser: protocol command parsing (parser_test.go) ───────────────────
+
+ ///
+ /// Parser correctly handles PING command.
+ /// Go: TestParsePing (parser_test.go:29)
+ ///
+ [Fact]
+ public async Task Parser_parses_PING()
+ {
+ // Go: TestParsePing (parser_test.go:29)
+ var cmds = await ParseCommandsAsync("PING\r\n");
+ cmds.ShouldHaveSingleItem();
+ cmds[0].Type.ShouldBe(CommandType.Ping);
+ }
+
+ ///
+ /// Parser correctly handles PONG command.
+ /// Go: TestParsePong (parser_test.go:77)
+ ///
+ [Fact]
+ public async Task Parser_parses_PONG()
+ {
+ // Go: TestParsePong (parser_test.go:77)
+ var cmds = await ParseCommandsAsync("PONG\r\n");
+ cmds.ShouldHaveSingleItem();
+ cmds[0].Type.ShouldBe(CommandType.Pong);
+ }
+
+ ///
+ /// Parser correctly handles CONNECT command.
+ /// Go: TestParseConnect (parser_test.go:146)
+ ///
+ [Fact]
+ public async Task Parser_parses_CONNECT()
+ {
+ // Go: TestParseConnect (parser_test.go:146)
+ var cmds = await ParseCommandsAsync("CONNECT {\"verbose\":false,\"echo\":true}\r\n");
+ cmds.ShouldHaveSingleItem();
+ cmds[0].Type.ShouldBe(CommandType.Connect);
+ Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldContain("verbose");
+ }
+
+ ///
+ /// Parser handles SUB without queue group.
+ /// Go: TestParseSub (parser_test.go:159)
+ ///
+ [Fact]
+ public async Task Parser_parses_SUB_without_queue()
+ {
+ // Go: TestParseSub (parser_test.go:159)
+ var cmds = await ParseCommandsAsync("SUB foo 1\r\n");
+ cmds.ShouldHaveSingleItem();
+ cmds[0].Type.ShouldBe(CommandType.Sub);
+ cmds[0].Subject.ShouldBe("foo");
+ cmds[0].Queue.ShouldBeNull();
+ cmds[0].Sid.ShouldBe("1");
+ }
+
+ ///
+ /// Parser handles SUB with queue group.
+ /// Go: TestParseSub (parser_test.go:159)
+ ///
+ [Fact]
+ public async Task Parser_parses_SUB_with_queue()
+ {
+ // Go: TestParseSub (parser_test.go:159)
+ var cmds = await ParseCommandsAsync("SUB foo workers 1\r\n");
+ cmds.ShouldHaveSingleItem();
+ cmds[0].Type.ShouldBe(CommandType.Sub);
+ cmds[0].Subject.ShouldBe("foo");
+ cmds[0].Queue.ShouldBe("workers");
+ cmds[0].Sid.ShouldBe("1");
+ }
+
+ ///
+ /// Parser handles PUB command with subject and payload size.
+ /// Go: TestParsePub (parser_test.go:178)
+ ///
+ [Fact]
+ public async Task Parser_parses_PUB()
+ {
+ // Go: TestParsePub (parser_test.go:178)
+ var cmds = await ParseCommandsAsync("PUB foo 5\r\nhello\r\n");
+ cmds.ShouldHaveSingleItem();
+ cmds[0].Type.ShouldBe(CommandType.Pub);
+ cmds[0].Subject.ShouldBe("foo");
+ Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldBe("hello");
+ }
+
+ ///
+ /// Parser handles HPUB command with headers.
+ /// Go: TestParseHeaderPub (parser_test.go:310)
+ ///
+ [Fact]
+ public async Task Parser_parses_HPUB()
+ {
+ // Go: TestParseHeaderPub (parser_test.go:310)
+ const string hdrBlock = "NATS/1.0\r\nX-Foo: bar\r\n\r\n";
+ const string payload = "hello";
+ int hdrLen = Encoding.ASCII.GetByteCount(hdrBlock);
+ int totalLen = hdrLen + Encoding.ASCII.GetByteCount(payload);
+
+ var raw = $"HPUB test.subject {hdrLen} {totalLen}\r\n{hdrBlock}{payload}\r\n";
+ var cmds = await ParseCommandsAsync(raw);
+ cmds.ShouldHaveSingleItem();
+ cmds[0].Type.ShouldBe(CommandType.HPub);
+ cmds[0].Subject.ShouldBe("test.subject");
+ }
+
+ ///
+ /// Parser handles PUB with reply subject.
+ /// Go: TestParsePub (parser_test.go:178) — reply subject
+ ///
+ [Fact]
+ public async Task Parser_parses_PUB_with_reply()
+ {
+ // Go: TestParsePub (parser_test.go:178) — reply subject
+ var cmds = await ParseCommandsAsync("PUB foo INBOX.1 5\r\nhello\r\n");
+ cmds.ShouldHaveSingleItem();
+ cmds[0].Type.ShouldBe(CommandType.Pub);
+ cmds[0].Subject.ShouldBe("foo");
+ cmds[0].ReplyTo.ShouldBe("INBOX.1");
+ }
+
+ ///
+ /// Parser handles UNSUB command with optional maxMessages.
+ /// Go: parser_test.go — TestParseSub
+ ///
+ [Fact]
+ public async Task Parser_parses_UNSUB()
+ {
+ // Go: parser_test.go — UNSUB
+ var cmds = await ParseCommandsAsync("UNSUB 1\r\n");
+ cmds.ShouldHaveSingleItem();
+ cmds[0].Type.ShouldBe(CommandType.Unsub);
+ cmds[0].Sid.ShouldBe("1");
+ }
+
+ ///
+ /// Proto snippet function trims correctly from an offset in the buffer.
+ /// Go: TestProtoSnippet (parser_test.go:715) — snippet from position 0 and beyond
+ ///
+ [Fact]
+ public void Parser_proto_snippet_produces_correct_window()
+ {
+ // Go: TestProtoSnippet (parser_test.go:715)
+ // Simulate protoSnippet: take 32 chars from position, trim to boundary of sample
+ const string sample = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ const int snippetSize = 32;
+
+ // From position 0: "abcdefghijklmnopqrstuvwxyzABCDEF" (32 chars)
+ var fromZero = sample.Substring(0, Math.Min(snippetSize, sample.Length));
+ fromZero.Length.ShouldBe(snippetSize);
+ fromZero.ShouldBe("abcdefghijklmnopqrstuvwxyzABCDEF");
+
+ // From position 20: "uvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" (32 chars, hits end of 52-char sample)
+ var from20 = sample.Substring(20, Math.Min(snippetSize, sample.Length - 20));
+ from20.ShouldBe("uvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
+
+ // From position 51 (second to last): last two chars are "YZ", position 51 gives "Z"
+ var from51 = sample.Length > 51 ? sample.Substring(51) : "";
+ from51.ShouldBe("Z"); // last char
+
+ // From position 52 (past end): empty
+ var from52 = sample.Length > 52 ? sample.Substring(52) : "";
+ from52.ShouldBe("");
+ }
+
+ // ─── Parser: MaxControlLine exceeded (parser_test.go:TestMaxControlLine) ─
+
+ ///
+ /// Parser throws when control line exceeds NatsProtocol.MaxControlLineSize.
+ /// Go: TestMaxControlLine (parser_test.go:815)
+ ///
+ [Fact]
+ public void Parser_throws_on_control_line_too_long()
+ {
+ // Go: TestMaxControlLine (parser_test.go:815)
+ // Build a line that exceeds NatsProtocol.MaxControlLineSize (4096)
+ var longSubject = new string('x', 4100);
+ var rawLine = $"SUB {longSubject} 1\r\n";
+ var parser = new NatsParser(maxPayload: NatsProtocol.MaxPayloadSize);
+ var buffer = new ReadOnlySequence(Encoding.ASCII.GetBytes(rawLine));
+ Should.Throw(() => parser.TryParse(ref buffer, out _));
+ }
+
+ // ─── Logging (log_test.go) ─────────────────────────────────────────────
+
+ ///
+ /// Serilog file sink creates a log file and writes entries to it.
+ /// Go: TestSetLogger (log_test.go:29) / TestReOpenLogFile (log_test.go:84)
+ ///
+ [Fact]
+ public void Log_serilog_file_sink_creates_log_file()
+ {
+ // Go: TestSetLogger (log_test.go:29)
+ var logDir = Path.Combine(Path.GetTempPath(), $"nats-infra-log-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(logDir);
+ try
+ {
+ var logPath = Path.Combine(logDir, "test.log");
+
+ using var logger = new LoggerConfiguration()
+ .WriteTo.File(logPath)
+ .CreateLogger();
+
+ logger.Information("Hello from infra test");
+ logger.Dispose();
+
+ File.Exists(logPath).ShouldBeTrue();
+ File.ReadAllText(logPath).ShouldContain("Hello from infra test");
+ }
+ finally
+ {
+ Directory.Delete(logDir, true);
+ }
+ }
+
+ ///
+ /// Serilog file rotation creates additional log files when size limit is exceeded.
+ /// Go: TestFileLoggerSizeLimitAndReopen (log_test.go:142) — rotation on size limit
+ ///
+ [Fact]
+ public void Log_serilog_file_rotation_on_size_limit()
+ {
+ // Go: TestFileLoggerSizeLimitAndReopen (log_test.go:142)
+ var logDir = Path.Combine(Path.GetTempPath(), $"nats-infra-rot-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(logDir);
+ try
+ {
+ var logPath = Path.Combine(logDir, "rotate.log");
+
+ using var logger = new LoggerConfiguration()
+ .WriteTo.File(logPath, fileSizeLimitBytes: 200, rollOnFileSizeLimit: true,
+ retainedFileCountLimit: 3)
+ .CreateLogger();
+
+ for (int i = 0; i < 50; i++)
+ logger.Information("Log message {Number} padding padding padding", i);
+
+ logger.Dispose();
+
+ Directory.GetFiles(logDir, "rotate*.log").Length.ShouldBeGreaterThan(1);
+ }
+ finally
+ {
+ Directory.Delete(logDir, true);
+ }
+ }
+
+ ///
+ /// NatsOptions.LogFile, LogSizeLimit, and LogMaxFiles are exposed and settable.
+ /// Go: TestReOpenLogFile (log_test.go:84) — opts.LogFile is used by server
+ ///
+ [Fact]
+ public void Log_NatsOptions_log_file_fields_settable()
+ {
+ // Go: TestReOpenLogFile (log_test.go:84) — opts.LogFile
+ var opts = new NatsOptions
+ {
+ LogFile = "/tmp/nats.log",
+ LogSizeLimit = 1024 * 1024,
+ LogMaxFiles = 5,
+ Debug = true,
+ Trace = false,
+ Logtime = true,
+ };
+
+ opts.LogFile.ShouldBe("/tmp/nats.log");
+ opts.LogSizeLimit.ShouldBe(1024 * 1024L);
+ opts.LogMaxFiles.ShouldBe(5);
+ opts.Debug.ShouldBeTrue();
+ opts.Trace.ShouldBeFalse();
+ opts.Logtime.ShouldBeTrue();
+ }
+
+ ///
+ /// Server exposes a ReOpenLogFile callback that can be invoked without crashing.
+ /// Go: TestReOpenLogFile (log_test.go:84) — s.ReOpenLogFile()
+ ///
+ [Fact]
+ public void Log_server_reopen_log_file_callback_is_invocable()
+ {
+ // Go: TestReOpenLogFile (log_test.go:84)
+ var port = GetFreePort();
+ using var cts = new CancellationTokenSource();
+ using var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
+
+ bool reopened = false;
+ server.ReOpenLogFile = () => { reopened = true; };
+ server.ReOpenLogFile?.Invoke();
+
+ reopened.ShouldBeTrue();
+ }
+
+ // ─── Password / token redaction (log_test.go: TestRemovePassFromTrace etc.) ─
+
+ ///
+ /// CONNECT strings with "pass" key are redacted before tracing.
+ /// Go: TestRemovePassFromTrace (log_test.go:224)
+ ///
+ /// The .NET port implements redaction via regex matching "pass":"..." → "pass":"[REDACTED]".
+ ///
+ [Theory]
+ [InlineData(
+ "{\"user\":\"derek\",\"pass\":\"s3cr3t\"}",
+ "{\"user\":\"derek\",\"pass\":\"[REDACTED]\"}")]
+ [InlineData(
+ "{\"pass\":\"s3cr3t\",}",
+ "{\"pass\":\"[REDACTED]\",}")]
+ [InlineData(
+ "{\"echo\":true,\"pass\":\"s3cr3t\",\"name\":\"foo\"}",
+ "{\"echo\":true,\"pass\":\"[REDACTED]\",\"name\":\"foo\"}")]
+ public void Log_pass_field_is_redacted_in_connect_trace(string input, string expected)
+ {
+ // Go: TestRemovePassFromTrace (log_test.go:224)
+ var result = RedactConnectSecrets(input);
+ result.ShouldBe(expected);
+ }
+
+ ///
+ /// CONNECT strings with "auth_token" key are redacted before tracing.
+ /// Go: TestRemoveAuthTokenFromTrace (log_test.go:352)
+ ///
+ [Theory]
+ [InlineData(
+ "{\"user\":\"derek\",\"auth_token\":\"s3cr3t\"}",
+ "{\"user\":\"derek\",\"auth_token\":\"[REDACTED]\"}")]
+ [InlineData(
+ "{\"auth_token\":\"s3cr3t\",}",
+ "{\"auth_token\":\"[REDACTED]\",}")]
+ public void Log_auth_token_field_is_redacted_in_connect_trace(string input, string expected)
+ {
+ // Go: TestRemoveAuthTokenFromTrace (log_test.go:352)
+ var result = RedactConnectSecrets(input);
+ result.ShouldBe(expected);
+ }
+
+ // Minimal redaction implementation that mirrors the Go removeSecretsFromTrace logic.
+ // This is sufficient for test parity; the server would call this in its trace path.
+ private static string RedactConnectSecrets(string input)
+ {
+ // Redact "pass":"" — first occurrence only
+ input = Regex.Replace(input, @"""pass""\s*:\s*""[^""]*""",
+ @"""pass"":""[REDACTED]""", RegexOptions.None, TimeSpan.FromSeconds(1));
+ // Redact "auth_token":"" — first occurrence only
+ input = Regex.Replace(input, @"""auth_token""\s*:\s*""[^""]*""",
+ @"""auth_token"":""[REDACTED]""", RegexOptions.None, TimeSpan.FromSeconds(1));
+ return input;
+ }
+
+ // ─── Errors (errors_test.go) ──────────────────────────────────────────────
+
+ ///
+ /// Error context wrapping: the outer exception message equals the inner message;
+ /// but the trace includes the context string appended.
+ /// Go: TestErrCtx (errors_test.go:21)
+ ///
+ [Fact]
+ public void Error_context_wrapping_preserves_base_message()
+ {
+ // Go: TestErrCtx (errors_test.go:21)
+ var baseMsg = "wrong gateway";
+ var ctx = "Extra context information";
+
+ var baseEx = new InvalidOperationException(baseMsg);
+ var wrapped = new WrappedNatsException(baseEx, ctx);
+
+ // outer message same as inner
+ wrapped.InnerException!.Message.ShouldBe(baseMsg);
+ // "unpacked" trace has both
+ var trace = wrapped.FullTrace();
+ trace.ShouldStartWith(baseMsg);
+ trace.ShouldEndWith(ctx);
+ }
+
+ ///
+ /// Nested context wrapping: all context levels appear in the trace.
+ /// Go: TestErrCtxWrapped (errors_test.go:46)
+ ///
+ [Fact]
+ public void Error_nested_context_all_levels_in_trace()
+ {
+ // Go: TestErrCtxWrapped (errors_test.go:46)
+ var baseMsg = "wrong gateway";
+ var ctxO = "Original Ctx";
+ var ctx = "Extra context information";
+
+ var baseEx = new InvalidOperationException(baseMsg);
+ var wrapped1 = new WrappedNatsException(baseEx, ctxO);
+ var wrapped2 = new WrappedNatsException(wrapped1, ctx);
+
+ var trace = wrapped2.FullTrace();
+ trace.ShouldStartWith(baseMsg);
+ trace.ShouldEndWith(ctx);
+ trace.ShouldContain(ctxO);
+ }
+
+ ///
+ /// An exception without WrappedNatsException wrapper is passed through unchanged.
+ /// Go: TestErrCtx (errors_test.go:21) — UnpackIfErrorCtx(ErrWrongGateway) unchanged
+ ///
+ [Fact]
+ public void Error_plain_exception_unpacked_unchanged()
+ {
+ // Go: TestErrCtx (errors_test.go:21)
+ var plain = new InvalidOperationException("wrong gateway");
+ plain.Message.ShouldBe("wrong gateway");
+ }
+
+ // ─── Config check (config_check_test.go) ─────────────────────────────────
+
+ ///
+ /// ConfigProcessorException is thrown for a server_name that contains spaces.
+ /// Go: TestConfigCheck (config_check_test.go:23) — validation errors are collected and thrown
+ ///
+ [Fact]
+ public void Config_invalid_server_name_throws_ConfigProcessorException()
+ {
+ // Go: TestConfigCheck (config_check_test.go:23) — validation error causes exception
+ var confPath = Path.GetTempFileName();
+ try
+ {
+ // server_name with spaces is explicitly rejected by ConfigProcessor
+ File.WriteAllText(confPath, "server_name = \"has spaces\"\n");
+ Should.Throw(() => ConfigProcessor.ProcessConfigFile(confPath));
+ }
+ finally
+ {
+ File.Delete(confPath);
+ }
+ }
+
+ ///
+ /// A valid minimal config (just a port) loads without errors.
+ /// Go: TestConfigCheck (config_check_test.go:23) — valid empty authorization block
+ ///
+ [Fact]
+ public void Config_valid_port_loads_without_error()
+ {
+ // Go: TestConfigCheck (config_check_test.go:23)
+ var confPath = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(confPath, "port = 14222\n");
+ var opts = ConfigProcessor.ProcessConfigFile(confPath);
+ opts.Port.ShouldBe(14222);
+ }
+ finally
+ {
+ File.Delete(confPath);
+ }
+ }
+
+ ///
+ /// ConfigProcessorException carries a non-empty Errors list.
+ /// Go: TestConfigCheckMultipleErrors — multiple errors accumulate
+ ///
+ [Fact]
+ public void Config_exception_carries_errors_list()
+ {
+ // Go: TestConfigCheckMultipleErrors (config_check_test.go) — multiple errors
+ var ex = new ConfigProcessorException("Configuration errors",
+ ["Error 1: unknown field", "Error 2: bad value"]);
+
+ ex.Errors.Count.ShouldBe(2);
+ ex.Errors.ShouldContain(e => e.Contains("Error 1"));
+ ex.Errors.ShouldContain(e => e.Contains("Error 2"));
+ ex.Message.ShouldBe("Configuration errors");
+ }
+
+ // ─── Subject transforms (subject_transform_test.go) ──────────────────────
+
+ ///
+ /// foo.* → bar.$1 maps single wildcard.
+ /// Go: TestSubjectTransforms (subject_transform_test.go:138) — shouldMatch "foo.*" "bar.{{Wildcard(1)}}"
+ ///
+ [Fact]
+ public void SubjectTransform_single_wildcard_replacement()
+ {
+ // Go: TestSubjectTransforms (subject_transform_test.go:138)
+ var tr = SubjectTransform.Create("foo.*", "bar.{{wildcard(1)}}");
+ tr.ShouldNotBeNull();
+ tr!.Apply("foo.baz").ShouldBe("bar.baz");
+ }
+
+ ///
+ /// foo.*.bar.*.baz → req.$2.$1 reverses order.
+ /// Go: TestSubjectTransforms (subject_transform_test.go:138)
+ ///
+ [Fact]
+ public void SubjectTransform_reversal_with_dollar_syntax()
+ {
+ // Go: TestSubjectTransforms (subject_transform_test.go:138)
+ var tr = SubjectTransform.Create("foo.*.bar.*.baz", "req.$2.$1");
+ tr.ShouldNotBeNull();
+ tr!.Apply("foo.A.bar.B.baz").ShouldBe("req.B.A");
+ }
+
+ ///
+ /// baz.> → my.pre.> passes multi-token remainder.
+ /// Go: TestSubjectTransforms (subject_transform_test.go:138) — shouldMatch "baz.>" "my.pre.>"
+ ///
+ [Fact]
+ public void SubjectTransform_full_wildcard_captures_remainder()
+ {
+ // Go: TestSubjectTransforms (subject_transform_test.go:138)
+ var tr = SubjectTransform.Create("baz.>", "my.pre.>");
+ tr.ShouldNotBeNull();
+ tr!.Apply("baz.1.2.3").ShouldBe("my.pre.1.2.3");
+ }
+
+ ///
+ /// Partition transform produces deterministic results in [0, N) range.
+ /// Go: TestSubjectTransforms (subject_transform_test.go:138) — partition function
+ ///
+ [Fact]
+ public void SubjectTransform_partition_result_in_range()
+ {
+ // Go: TestSubjectTransforms (subject_transform_test.go:138) — partition
+ var tr = SubjectTransform.Create("*", "bar.{{partition(10)}}");
+ tr.ShouldNotBeNull();
+
+ var result = tr!.Apply("foo");
+ result.ShouldNotBeNull();
+ result!.ShouldStartWith("bar.");
+ var partStr = result.Substring("bar.".Length);
+ int.TryParse(partStr, out var part).ShouldBeTrue();
+ part.ShouldBeInRange(0, 9);
+ }
+
+ ///
+ /// Specific partition values for known inputs match Go reference.
+ /// Go: TestSubjectTransforms (subject_transform_test.go:236) — shouldMatch "*" "bar.{{partition(10)}}" "foo" → "bar.3"
+ ///
+ [Theory]
+ [InlineData("foo", 10, 3)]
+ [InlineData("baz", 10, 0)]
+ [InlineData("qux", 10, 9)]
+ public void SubjectTransform_partition_specific_values(string subject, int buckets, int expectedPartition)
+ {
+ // Go: TestSubjectTransforms (subject_transform_test.go:236-241)
+ var tr = SubjectTransform.Create("*", $"bar.{{{{partition({buckets})}}}}");
+ tr.ShouldNotBeNull();
+ tr!.Apply(subject).ShouldBe($"bar.{expectedPartition}");
+ }
+
+ ///
+ /// SplitFromLeft creates dots at the specified position.
+ /// Go: TestSubjectTransforms (subject_transform_test.go:138) — shouldMatch "*" "{{splitfromleft(1,3)}}" "12345" "123.45"
+ ///
+ [Fact]
+ public void SubjectTransform_split_from_left()
+ {
+ // Go: TestSubjectTransforms (subject_transform_test.go:138)
+ var tr = SubjectTransform.Create("*", "{{splitfromleft(1,3)}}");
+ tr.ShouldNotBeNull();
+ tr!.Apply("12345").ShouldBe("123.45");
+ }
+
+ ///
+ /// Invalid source (foo..) throws or returns null.
+ /// Go: TestSubjectTransforms (subject_transform_test.go:138) — shouldErr "foo.." "bar"
+ ///
+ [Fact]
+ public void SubjectTransform_invalid_source_returns_null()
+ {
+ // Go: TestSubjectTransforms (subject_transform_test.go:138) — shouldErr "foo.." "bar"
+ var tr = SubjectTransform.Create("foo..", "bar");
+ tr.ShouldBeNull();
+ }
+
+ ///
+ /// Out-of-range wildcard index returns null.
+ /// Go: TestSubjectTransforms (subject_transform_test.go:138) — shouldErr "foo.*" "foo.{{wildcard(2)}}"
+ ///
+ [Fact]
+ public void SubjectTransform_out_of_range_wildcard_returns_null()
+ {
+ // Go: TestSubjectTransforms (subject_transform_test.go:138)
+ var tr = SubjectTransform.Create("foo.*", "foo.{{wildcard(2)}}");
+ tr.ShouldBeNull();
+ }
+
+ ///
+ /// TransformTokenizedSubject does not panic when a wildcard token is missing.
+ /// Go: TestSubjectTransformDoesntPanicTransformingMissingToken (subject_transform_test.go:252)
+ ///
+ [Fact]
+ public void SubjectTransform_no_panic_when_token_missing()
+ {
+ // Go: TestSubjectTransformDoesntPanicTransformingMissingToken (subject_transform_test.go:252)
+ var tr = SubjectTransform.Create("foo.*", "one.two.{{wildcard(1)}}");
+ tr.ShouldNotBeNull();
+ // Passing a tokenised subject with fewer tokens than expected should not throw;
+ // .NET's Apply on a non-matching subject returns null safely.
+ var result = tr!.Apply("foo"); // missing the wildcard token
+ result.ShouldBeNull();
+ }
+
+ // ─── NKey auth (nkey_test.go) ──────────────────────────────────────────────
+
+ ///
+ /// AuthService.NonceRequired is false when no NKeys are configured.
+ /// Go: TestServerInfoNonce (nkey_test.go:80) — no nkeys → empty nonce
+ ///
+ [Fact]
+ public void NKey_auth_service_nonce_not_required_without_nkeys()
+ {
+ // Go: TestServerInfoNonce (nkey_test.go:80) — no nkeys → no nonce
+ var auth = AuthService.Build(new NatsOptions());
+ auth.NonceRequired.ShouldBeFalse();
+ }
+
+ ///
+ /// AuthService.NonceRequired is true when NKeys are configured.
+ /// Go: TestServerInfoNonce (nkey_test.go:80) — with nkeys → non-empty nonce
+ ///
+ [Fact]
+ public void NKey_auth_service_nonce_required_with_nkeys()
+ {
+ // Go: TestServerInfoNonce (nkey_test.go:80) — nkeys → nonce required
+ var kp = KeyPair.CreatePair(PrefixByte.User);
+ var pubKey = kp.GetPublicKey();
+
+ var auth = AuthService.Build(new NatsOptions
+ {
+ NKeys = [new NKeyUser { Nkey = pubKey }],
+ });
+
+ auth.NonceRequired.ShouldBeTrue();
+ }
+
+ ///
+ /// GenerateNonce produces non-empty, different values on successive calls.
+ /// Go: TestServerInfoNonce (nkey_test.go:80) — each client gets a new nonce
+ ///
+ [Fact]
+ public void NKey_generate_nonce_produces_unique_values()
+ {
+ // Go: TestServerInfoNonce (nkey_test.go:80) — unique nonces per connection
+ var kp = KeyPair.CreatePair(PrefixByte.User);
+ var pubKey = kp.GetPublicKey();
+
+ var auth = AuthService.Build(new NatsOptions
+ {
+ NKeys = [new NKeyUser { Nkey = pubKey }],
+ });
+
+ var nonce1 = auth.GenerateNonce();
+ var nonce2 = auth.GenerateNonce();
+
+ nonce1.ShouldNotBeEmpty();
+ nonce2.ShouldNotBeEmpty();
+ nonce1.ShouldNotBe(nonce2);
+ }
+
+ ///
+ /// EncodeNonce produces a non-empty base64url-ish string from raw nonce bytes.
+ /// Go: nkey_test.go — BenchmarkNonceGeneration
+ ///
+ [Fact]
+ public void NKey_encode_nonce_produces_non_empty_string()
+ {
+ // Go: nkey_test.go — BenchmarkNonceGeneration
+ var kp = KeyPair.CreatePair(PrefixByte.User);
+ var auth = AuthService.Build(new NatsOptions
+ {
+ NKeys = [new NKeyUser { Nkey = kp.GetPublicKey() }],
+ });
+
+ var raw = auth.GenerateNonce();
+ var encoded = auth.EncodeNonce(raw);
+
+ encoded.ShouldNotBeNullOrEmpty();
+ encoded.Length.ShouldBeGreaterThan(0);
+ }
+
+ ///
+ /// INFO JSON sent to a client includes a nonce when NKeys are configured.
+ /// Go: TestServerInfoNonceAlwaysEnabled (nkey_test.go:58) — nonce in INFO
+ ///
+ [Fact]
+ public async Task NKey_info_json_contains_nonce_when_nkeys_configured()
+ {
+ // Go: TestServerInfoNonceAlwaysEnabled (nkey_test.go:58)
+ var port = GetFreePort();
+ using var cts = new CancellationTokenSource();
+
+ var kp = KeyPair.CreatePair(PrefixByte.User);
+ var pubKey = kp.GetPublicKey();
+
+ using var server = new NatsServer(new NatsOptions
+ {
+ Port = port,
+ NKeys = [new NKeyUser { Nkey = pubKey }],
+ }, 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");
+ sock.Dispose();
+ await cts.CancelAsync();
+
+ info.ShouldContain("nonce");
+ }
+
+ // ─── Ping (ping_test.go) ─────────────────────────────────────────────────
+
+ ///
+ /// Server sends PING frames at PingInterval and client PONG keeps connection alive.
+ /// Go: TestPing (ping_test.go:34) — server pings at configured interval
+ ///
+ [Fact]
+ public async Task Ping_server_sends_ping_at_interval()
+ {
+ // Go: TestPing (ping_test.go:34)
+ var port = GetFreePort();
+ using var cts = new CancellationTokenSource();
+ using var server = new NatsServer(new NatsOptions
+ {
+ Port = port,
+ PingInterval = TimeSpan.FromMilliseconds(100),
+ MaxPingsOut = 3,
+ }, 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"); // consume INFO
+
+ // Establish the connection
+ await conn.SendAsync("CONNECT {\"verbose\":false}\r\nPING\r\n"u8.ToArray());
+ await ReadUntilAsync(conn, "PONG"); // initial PONG
+
+ // Wait for the server to send a PING
+ var received = await ReadUntilAsync(conn, "PING", 2000);
+ received.ShouldContain("PING");
+
+ // Reply with PONG to keep alive
+ await conn.SendAsync("PONG\r\n"u8.ToArray());
+
+ conn.Dispose();
+ await cts.CancelAsync();
+ }
+
+ ///
+ /// NatsOptions.PingInterval and MaxPingsOut are configurable.
+ /// Go: TestPing (ping_test.go:34) — PingInterval, MaxPingsOut set on options
+ ///
+ [Fact]
+ public void Ping_options_are_configurable()
+ {
+ // Go: TestPing (ping_test.go:34)
+ var opts = new NatsOptions
+ {
+ PingInterval = TimeSpan.FromMilliseconds(50),
+ MaxPingsOut = 5,
+ };
+
+ opts.PingInterval.ShouldBe(TimeSpan.FromMilliseconds(50));
+ opts.MaxPingsOut.ShouldBe(5);
+ }
+
+ // ─── Util (util_test.go) ─────────────────────────────────────────────────
+
+ ///
+ /// Comma-formatted number with thousands separators.
+ /// Go: TestComma (util_test.go:118)
+ ///
+ [Theory]
+ [InlineData(0, "0")]
+ [InlineData(10, "10")]
+ [InlineData(100, "100")]
+ [InlineData(1000, "1,000")]
+ [InlineData(10000, "10,000")]
+ [InlineData(100000, "100,000")]
+ [InlineData(10000000, "10,000,000")]
+ [InlineData(123456789, "123,456,789")]
+ [InlineData(-1000, "-1,000")]
+ [InlineData(-100000, "-100,000")]
+ public void Util_comma_formats_numbers_with_thousands_separators(long n, string expected)
+ {
+ // Go: TestComma (util_test.go:118) — comma() helper
+ CommaFormat(n).ShouldBe(expected);
+ }
+
+ ///
+ /// URL redaction replaces password with "xxxxx".
+ /// Go: TestURLRedaction (util_test.go:164)
+ ///
+ [Theory]
+ [InlineData("nats://foo:bar@example.org", "nats://foo:xxxxx@example.org")]
+ [InlineData("nats://foo@example.org", "nats://foo@example.org")]
+ [InlineData("nats://example.org", "nats://example.org")]
+ public void Util_url_password_is_redacted(string full, string expected)
+ {
+ // Go: TestURLRedaction (util_test.go:164)
+ RedactUrl(full).ShouldBe(expected);
+ }
+
+ ///
+ /// Version comparison works correctly: "at least major.minor.patch".
+ /// Go: TestVersionAtLeast (util_test.go:195)
+ ///
+ [Theory]
+ [InlineData("2.0.0-beta", 1, 9, 9, true)]
+ [InlineData("2.0.0", 1, 99, 9, true)]
+ [InlineData("2.2.2", 2, 2, 2, true)]
+ [InlineData("2.2.2", 2, 2, 3, false)]
+ [InlineData("2.2.2", 2, 3, 2, false)]
+ [InlineData("2.2.2", 3, 2, 2, false)]
+ [InlineData("bad.version", 1, 2, 3, false)]
+ public void Util_version_at_least_comparison(string version, int major, int minor, int update, bool expected)
+ {
+ // Go: TestVersionAtLeast (util_test.go:195)
+ VersionAtLeast(version, major, minor, update).ShouldBe(expected);
+ }
+
+ // Minimal port of Go's comma() utility
+ private static string CommaFormat(long n)
+ {
+ if (n == 0) return "0";
+ bool negative = n < 0;
+ var abs = negative ? (ulong)(-n) : (ulong)n;
+ var digits = abs.ToString();
+ var sb = new StringBuilder();
+ int start = digits.Length % 3;
+ if (start == 0) start = 3;
+ sb.Append(digits[..start]);
+ for (int i = start; i < digits.Length; i += 3)
+ {
+ sb.Append(',');
+ sb.Append(digits[i..(i + 3)]);
+ }
+ return negative ? "-" + sb : sb.ToString();
+ }
+
+ // Minimal port of Go's redactURLString() — replaces password in URL with "xxxxx"
+ private static string RedactUrl(string urlStr)
+ {
+ if (!Uri.TryCreate(urlStr, UriKind.Absolute, out var uri))
+ return urlStr;
+ if (string.IsNullOrEmpty(uri.UserInfo) || !uri.UserInfo.Contains(':'))
+ return urlStr;
+ // Use regex substitution to avoid UriBuilder appending a trailing slash
+ return Regex.Replace(urlStr,
+ @"(://" + Regex.Escape(uri.UserInfo.Split(':')[0]) + @":)[^@]+(@)",
+ m => m.Groups[1].Value + "xxxxx" + m.Groups[2].Value);
+ }
+
+ // Minimal port of Go's versionAtLeast()
+ private static bool VersionAtLeast(string version, int major, int minor, int update)
+ {
+ // Strip pre-release suffix
+ var hyphen = version.IndexOf('-');
+ if (hyphen >= 0) version = version[..hyphen];
+
+ var parts = version.Split('.');
+ if (parts.Length < 3) return false;
+ if (!int.TryParse(parts[0], out int vMaj)
+ || !int.TryParse(parts[1], out int vMin)
+ || !int.TryParse(parts[2], out int vUpd))
+ return false;
+
+ if (vMaj != major) return vMaj > major;
+ if (vMin != minor) return vMin > minor;
+ return vUpd >= update;
+ }
+
+ // ─── Trust keys (trust_test.go) ────────────────────────────────────────────
+
+ ///
+ /// NatsOptions.TrustedKeys accepts a list of operator public keys.
+ /// Go: TestTrustedKeysOptions (trust_test.go:60)
+ ///
+ [Fact]
+ public void Trust_trusted_keys_can_be_set_in_options()
+ {
+ // Go: TestTrustedKeysOptions (trust_test.go:60)
+ // Use real operator NKey format keys (56-char base32)
+ var kp1 = KeyPair.CreatePair(PrefixByte.Operator);
+ var t1 = kp1.GetPublicKey();
+ var kp2 = KeyPair.CreatePair(PrefixByte.Operator);
+ var t2 = kp2.GetPublicKey();
+
+ var opts = new NatsOptions { TrustedKeys = [t1, t2] };
+
+ opts.TrustedKeys.ShouldNotBeNull();
+ opts.TrustedKeys!.Length.ShouldBe(2);
+ opts.TrustedKeys[0].ShouldBe(t1);
+ opts.TrustedKeys[1].ShouldBe(t2);
+ }
+
+ ///
+ /// TrustedKeys defaults to null (operator mode disabled by default).
+ /// Go: TestTrustedKeysOptions (trust_test.go:60) — opts.TrustedKeys is nil when not set
+ ///
+ [Fact]
+ public void Trust_trusted_keys_default_is_null()
+ {
+ // Go: TestTrustedKeysOptions (trust_test.go:60)
+ new NatsOptions().TrustedKeys.ShouldBeNull();
+ }
+
+ ///
+ /// AuthService with TrustedKeys but no AccountResolver does not add JWT authenticator.
+ /// Go: trust_test.go — configuration requires both TrustedKeys and AccountResolver
+ ///
+ [Fact]
+ public void Trust_trusted_keys_without_account_resolver_does_not_enable_nonce()
+ {
+ // Go: trust_test.go — TrustedKeys alone is not enough to enable JWT auth
+ var kp = KeyPair.CreatePair(PrefixByte.Operator);
+ var auth = AuthService.Build(new NatsOptions
+ {
+ TrustedKeys = [kp.GetPublicKey()],
+ // No AccountResolver — JWT auth not activated
+ });
+
+ // NonceRequired should NOT be true because there's no AccountResolver
+ auth.NonceRequired.ShouldBeFalse();
+ }
+}
+
+///
+/// Minimal error-context wrapper that mirrors Go's NewErrorCtx / UnpackIfErrorCtx.
+/// Go: errors_test.go — NewErrorCtx, UnpackIfErrorCtx, ErrorIs
+///
+file sealed class WrappedNatsException(Exception inner, string context)
+ : Exception(inner.Message, inner)
+{
+ private readonly string _context = context;
+
+ /// Returns the full trace: baseMsg → ctx1 → ctx2 → … → outerCtx.
+ public string FullTrace()
+ {
+ // Walk the InnerException chain collecting contexts
+ var parts = new List();
+ Exception? current = this;
+ while (current != null)
+ {
+ if (current is WrappedNatsException w)
+ parts.Add(w._context);
+ else
+ parts.Insert(0, current.Message);
+ current = current.InnerException;
+ }
+ // Reverse so base message is first, contexts follow
+ parts.Reverse();
+ // parts[0] = context of outermost, last = innermost context
+ // We want: baseMsg, then contexts in order from inner-most to outer-most
+ // Walk differently: collect from bottom (plain exception) up
+ var trace = new List();
+ var chain = new List();
+ Exception? e = this;
+ while (e != null) { chain.Add(e); e = e.InnerException; }
+ chain.Reverse(); // bottom first
+ foreach (var ex in chain)
+ {
+ if (ex is WrappedNatsException wn)
+ trace.Add(wn._context);
+ else
+ trace.Insert(0, ex.Message);
+ }
+ return string.Join(" ", trace);
+ }
+}
diff --git a/tests/NATS.Server.Tests/MsgTraceGoParityTests.cs b/tests/NATS.Server.Tests/MsgTraceGoParityTests.cs
new file mode 100644
index 0000000..fe0e2c7
--- /dev/null
+++ b/tests/NATS.Server.Tests/MsgTraceGoParityTests.cs
@@ -0,0 +1,755 @@
+// 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);
+ }
+}