// Reference: golang/nats-server/server/msgtrace_test.go // Go test suite: 33 tests covering Nats-Trace-Dest header propagation and // $SYS.TRACE.> event publication. // // The .NET port has MessageTraceContext (Protocol/MessageTraceContext.cs), // ClientFlags.TraceMode (ClientFlags.cs), NatsHeaderParser (Protocol/NatsHeaderParser.cs) // and per-server Trace/TraceVerbose/MaxTracedMsgLen options (NatsOptions.cs). // Full $SYS.TRACE.> event emission is not yet implemented; these tests cover the // infrastructure that must be in place first: trace context capture, header // propagation via HPUB/HMSG, and trace-mode flag behaviour. using System.Net; using System.Net.Sockets; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using NATS.Server; using NATS.Server.Protocol; using NATS.Server.TestUtilities; namespace NATS.Server.Core.Tests; /// /// Tests for message trace infrastructure: MessageTraceContext population, /// HPUB/HMSG trace header propagation, ClientFlags.TraceMode, NatsHeaderParser, /// and server trace options. /// /// Go reference: golang/nats-server/server/msgtrace_test.go /// public class MessageTraceTests : IAsyncLifetime { private readonly NatsServer _server; private readonly int _port; private readonly CancellationTokenSource _cts = new(); public MessageTraceTests() { _port = TestPortAllocator.GetFreePort(); _server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance); } public async Task InitializeAsync() { _ = _server.StartAsync(_cts.Token); await _server.WaitForReadyAsync(); } public async Task DisposeAsync() { await _cts.CancelAsync(); _server.Dispose(); } private async Task ConnectWithHeadersAsync(string? clientName = null, string? lang = null, string? version = null) { var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await sock.ConnectAsync(IPAddress.Loopback, _port); await SocketTestHelper.ReadUntilAsync(sock, "\r\n"); // discard INFO var connectJson = BuildConnectJson(headers: true, name: clientName, lang: lang, version: version); await sock.SendAsync(Encoding.ASCII.GetBytes($"CONNECT {connectJson}\r\n")); return sock; } private static string BuildConnectJson( bool headers = true, bool noResponders = false, string? name = null, string? lang = null, string? version = null) { var parts = new List { $"\"headers\":{(headers ? "true" : "false")}" }; if (noResponders) parts.Add("\"no_responders\":true"); if (name != null) parts.Add($"\"name\":\"{name}\""); if (lang != null) parts.Add($"\"lang\":\"{lang}\""); if (version != null) parts.Add($"\"ver\":\"{version}\""); return "{" + string.Join(",", parts) + "}"; } // ------------------------------------------------------------------------- // MessageTraceContext unit tests // Reference: msgtrace_test.go — trace context is populated from CONNECT opts // ------------------------------------------------------------------------- /// /// MessageTraceContext.Empty has null client identity fields and false /// headers-enabled. Mirrors Go's zero-value trace context. /// Go reference: msgtrace_test.go — TestMsgTraceBasic setup /// [Fact] public void MessageTraceContext_empty_has_null_fields() { var ctx = MessageTraceContext.Empty; ctx.ClientName.ShouldBeNull(); ctx.ClientLang.ShouldBeNull(); ctx.ClientVersion.ShouldBeNull(); ctx.HeadersEnabled.ShouldBeFalse(); } /// /// MessageTraceContext.CreateFromConnect with null options returns Empty. /// Go reference: msgtrace_test.go — trace context defaults /// [Fact] public void MessageTraceContext_create_from_null_opts_returns_empty() { var ctx = MessageTraceContext.CreateFromConnect(null); ctx.ShouldBe(MessageTraceContext.Empty); } /// /// MessageTraceContext.CreateFromConnect captures client name, lang, version, /// and headers flag from the parsed ClientOptions. /// Go reference: msgtrace_test.go — TestMsgTraceBasic, client identity in trace events /// [Fact] public void MessageTraceContext_captures_client_identity_from_connect_options() { var opts = new ClientOptions { Name = "tracer-client", Lang = "nats.go", Version = "1.30.0", Headers = true, }; var ctx = MessageTraceContext.CreateFromConnect(opts); ctx.ClientName.ShouldBe("tracer-client"); ctx.ClientLang.ShouldBe("nats.go"); ctx.ClientVersion.ShouldBe("1.30.0"); ctx.HeadersEnabled.ShouldBeTrue(); } /// /// A client without headers support produces a trace context with /// HeadersEnabled = false — that client cannot use Nats-Trace-Dest header. /// Go reference: msgtrace_test.go — clients must have headers to receive trace events /// [Fact] public void MessageTraceContext_headers_disabled_when_connect_opts_headers_false() { var opts = new ClientOptions { Name = "legacy", Headers = false }; var ctx = MessageTraceContext.CreateFromConnect(opts); ctx.HeadersEnabled.ShouldBeFalse(); ctx.ClientName.ShouldBe("legacy"); } /// /// MessageTraceContext is a record — two instances with the same values are equal. /// Go reference: msgtrace_test.go — deterministic identity comparison /// [Fact] public void MessageTraceContext_record_equality_compares_by_value() { var a = new MessageTraceContext("myapp", "nats.go", "1.0", true); var b = new MessageTraceContext("myapp", "nats.go", "1.0", true); a.ShouldBe(b); a.GetHashCode().ShouldBe(b.GetHashCode()); } // ------------------------------------------------------------------------- // NatsHeaderParser — trace header parsing // Reference: msgtrace_test.go — Nats-Trace-Dest header is a regular NATS header // ------------------------------------------------------------------------- /// /// NatsHeaderParser correctly parses a Nats-Trace-Dest header from an HPUB block. /// The trace destination header identifies where trace events should be published. /// Go reference: msgtrace_test.go — TestMsgTraceBasic HPUB with Nats-Trace-Dest /// [Fact] public void NatsHeaderParser_parses_trace_dest_header() { // NATS/1.0\r\nNats-Trace-Dest: trace.inbox\r\n\r\n const string rawHeaders = "NATS/1.0\r\nNats-Trace-Dest: trace.inbox\r\n\r\n"; var bytes = Encoding.ASCII.GetBytes(rawHeaders); var headers = NatsHeaderParser.Parse(bytes); headers.ShouldNotBe(NatsHeaders.Invalid); headers.Headers.ContainsKey("Nats-Trace-Dest").ShouldBeTrue(); headers.Headers["Nats-Trace-Dest"].ShouldContain("trace.inbox"); } /// /// NatsHeaderParser returns NatsHeaders.Invalid when data does not start /// with the NATS/1.0 prefix — guards against corrupted trace header blocks. /// Go reference: msgtrace_test.go — protocol validation /// [Fact] public void NatsHeaderParser_returns_invalid_for_bad_prefix() { var bytes = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n"u8.ToArray(); var headers = NatsHeaderParser.Parse(bytes); headers.ShouldBe(NatsHeaders.Invalid); } /// /// NatsHeaderParser handles an empty header block (NATS/1.0 with no headers). /// A trace destination header may be absent — the message is then not traced. /// Go reference: msgtrace_test.go — non-traced messages have no Nats-Trace-Dest /// [Fact] public void NatsHeaderParser_parses_empty_nats_header_block() { const string rawHeaders = "NATS/1.0\r\n\r\n"; var bytes = Encoding.ASCII.GetBytes(rawHeaders); var headers = NatsHeaderParser.Parse(bytes); headers.ShouldNotBe(NatsHeaders.Invalid); headers.Status.ShouldBe(0); headers.Headers.Count.ShouldBe(0); } /// /// NatsHeaderParser handles multiple headers in one block, matching the case /// where Nats-Trace-Dest appears alongside other application headers. /// Go reference: msgtrace_test.go — TestMsgTraceWithHeaders /// [Fact] public void NatsHeaderParser_parses_multiple_headers_including_trace_dest() { const string rawHeaders = "NATS/1.0\r\n" + "X-App-Id: 42\r\n" + "Nats-Trace-Dest: my.trace.inbox\r\n" + "X-Correlation: abc123\r\n" + "\r\n"; var bytes = Encoding.ASCII.GetBytes(rawHeaders); var headers = NatsHeaderParser.Parse(bytes); 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("abc123"); } /// /// Header lookup is case-insensitive, so "nats-trace-dest" and "Nats-Trace-Dest" /// resolve to the same key (matches Go's http.Header case-folding behaviour). /// Go reference: msgtrace_test.go — case-insensitive header access /// [Fact] public void NatsHeaderParser_header_lookup_is_case_insensitive() { const string rawHeaders = "NATS/1.0\r\nNats-Trace-Dest: inbox.trace\r\n\r\n"; var bytes = Encoding.ASCII.GetBytes(rawHeaders); var headers = NatsHeaderParser.Parse(bytes); 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 HPUB/HMSG trace header propagation // Reference: msgtrace_test.go — Nats-Trace-Dest header preserved in delivery // ------------------------------------------------------------------------- /// /// A Nats-Trace-Dest header sent in an HPUB is delivered verbatim in the /// HMSG to the subscriber. The server must not strip or modify trace headers. /// Go reference: msgtrace_test.go — TestMsgTraceBasic, header pass-through /// [Fact] public async Task Hpub_with_trace_dest_header_delivered_verbatim_to_subscriber() { using var sub = await ConnectWithHeadersAsync(); using var pub = await ConnectWithHeadersAsync(); await sub.SendAsync(Encoding.ASCII.GetBytes("SUB trace.test 1\r\n")); await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n")); await SocketTestHelper.ReadUntilAsync(sub, "PONG"); // Build HPUB with Nats-Trace-Dest header // Header block: "NATS/1.0\r\nNats-Trace-Dest: trace.inbox\r\n\r\n" const string headerBlock = "NATS/1.0\r\nNats-Trace-Dest: trace.inbox\r\n\r\n"; const string payload = "hello"; int hdrLen = Encoding.ASCII.GetByteCount(headerBlock); int totalLen = hdrLen + Encoding.ASCII.GetByteCount(payload); var hpub = $"HPUB trace.test {hdrLen} {totalLen}\r\n{headerBlock}{payload}\r\n"; await pub.SendAsync(Encoding.ASCII.GetBytes(hpub)); var received = await SocketTestHelper.ReadUntilAsync(sub, "Nats-Trace-Dest"); received.ShouldContain("HMSG trace.test"); received.ShouldContain("Nats-Trace-Dest: trace.inbox"); received.ShouldContain("hello"); } /// /// A Nats-Trace-Dest header is preserved when the message matches a wildcard /// subscription. Wildcard matching must not drop or corrupt headers. /// Go reference: msgtrace_test.go — TestMsgTraceWithWildcardSubscription /// [Fact] public async Task Hpub_trace_dest_header_preserved_through_wildcard_subscription() { using var sub = await ConnectWithHeadersAsync(); using var pub = await ConnectWithHeadersAsync(); // Subscribe to wildcard await sub.SendAsync(Encoding.ASCII.GetBytes("SUB trace.* 1\r\n")); await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n")); await SocketTestHelper.ReadUntilAsync(sub, "PONG"); const string headerBlock = "NATS/1.0\r\nNats-Trace-Dest: t.inbox.1\r\n\r\n"; const string payload = "wildcard-msg"; int hdrLen = Encoding.ASCII.GetByteCount(headerBlock); int totalLen = hdrLen + Encoding.ASCII.GetByteCount(payload); var hpub = $"HPUB trace.subject {hdrLen} {totalLen}\r\n{headerBlock}{payload}\r\n"; await pub.SendAsync(Encoding.ASCII.GetBytes(hpub)); var received = await SocketTestHelper.ReadUntilAsync(sub, "Nats-Trace-Dest"); received.ShouldContain("HMSG trace.subject"); received.ShouldContain("Nats-Trace-Dest: t.inbox.1"); received.ShouldContain("wildcard-msg"); } /// /// HPUB with a trace header delivered to a queue group subscriber preserves /// the header. Queue group routing must not strip trace context. /// Go reference: msgtrace_test.go — TestMsgTraceQueueGroup /// [Fact] public async Task Hpub_trace_dest_header_preserved_through_queue_group_delivery() { using var qsub = await ConnectWithHeadersAsync(); using var pub = await ConnectWithHeadersAsync(); // Queue group subscription await qsub.SendAsync(Encoding.ASCII.GetBytes("SUB trace.q workers 1\r\n")); await qsub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n")); await SocketTestHelper.ReadUntilAsync(qsub, "PONG"); const string headerBlock = "NATS/1.0\r\nNats-Trace-Dest: qg.trace\r\n\r\n"; const string payload = "queued"; int hdrLen = Encoding.ASCII.GetByteCount(headerBlock); int totalLen = hdrLen + Encoding.ASCII.GetByteCount(payload); var hpub = $"HPUB trace.q {hdrLen} {totalLen}\r\n{headerBlock}{payload}\r\n"; await pub.SendAsync(Encoding.ASCII.GetBytes(hpub)); var received = await SocketTestHelper.ReadUntilAsync(qsub, "Nats-Trace-Dest"); received.ShouldContain("Nats-Trace-Dest: qg.trace"); received.ShouldContain("queued"); } /// /// Multiple custom headers alongside Nats-Trace-Dest are all delivered intact. /// The server must preserve the full header block, not just the trace header. /// Go reference: msgtrace_test.go — TestMsgTraceWithHeaders /// [Fact] public async Task Hpub_multiple_headers_with_trace_dest_all_delivered_intact() { using var sub = await ConnectWithHeadersAsync(); using var pub = await ConnectWithHeadersAsync(); await sub.SendAsync(Encoding.ASCII.GetBytes("SUB multi.hdr 1\r\n")); await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n")); await SocketTestHelper.ReadUntilAsync(sub, "PONG"); const string headerBlock = "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(headerBlock); int totalLen = hdrLen + Encoding.ASCII.GetByteCount(payload); var hpub = $"HPUB multi.hdr {hdrLen} {totalLen}\r\n{headerBlock}{payload}\r\n"; await pub.SendAsync(Encoding.ASCII.GetBytes(hpub)); var received = await SocketTestHelper.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"); } /// /// HPUB with a very long trace ID (256 chars) is accepted and forwarded. The /// server must not truncate long header values. /// Go reference: msgtrace_test.go — TestMsgTraceLongTraceId /// [Fact] public async Task Hpub_very_long_trace_id_is_preserved() { using var sub = await ConnectWithHeadersAsync(); using var pub = await ConnectWithHeadersAsync(); await sub.SendAsync(Encoding.ASCII.GetBytes("SUB trace.long 1\r\n")); await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n")); await SocketTestHelper.ReadUntilAsync(sub, "PONG"); var longId = new string('a', 256); var headerBlock = $"NATS/1.0\r\nNats-Trace-Dest: {longId}\r\n\r\n"; const string payload = "x"; int hdrLen = Encoding.ASCII.GetByteCount(headerBlock); int totalLen = hdrLen + 1; var hpub = $"HPUB trace.long {hdrLen} {totalLen}\r\n{headerBlock}{payload}\r\n"; await pub.SendAsync(Encoding.ASCII.GetBytes(hpub)); var received = await SocketTestHelper.ReadUntilAsync(sub, longId); received.ShouldContain(longId); } // ------------------------------------------------------------------------- // Server trace options // Reference: msgtrace_test.go — server-side Trace / TraceVerbose / MaxTracedMsgLen // ------------------------------------------------------------------------- /// /// NatsOptions.Trace is false by default. Server-level tracing is opt-in. /// Go reference: opts.go default — trace=false /// [Fact] public void NatsOptions_trace_is_false_by_default() { var opts = new NatsOptions(); opts.Trace.ShouldBeFalse(); } /// /// NatsOptions.TraceVerbose is false by default. /// Go reference: opts.go — trace_verbose=false /// [Fact] public void NatsOptions_trace_verbose_is_false_by_default() { var opts = new NatsOptions(); opts.TraceVerbose.ShouldBeFalse(); } /// /// NatsOptions.MaxTracedMsgLen is 0 by default (unlimited). /// Go reference: opts.go — max_traced_msg_len default=0 /// [Fact] public void NatsOptions_max_traced_msg_len_is_zero_by_default() { var opts = new NatsOptions(); opts.MaxTracedMsgLen.ShouldBe(0); } /// /// A server created with Trace=true starts and accepts connections normally. /// Enabling trace mode must not prevent the server from becoming ready. /// Go reference: msgtrace_test.go — test server setup with trace enabled /// [Fact] public async Task Server_with_trace_enabled_starts_and_accepts_connections() { var port = TestPortAllocator.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 SocketTestHelper.ReadUntilAsync(sock, "\r\n"); info.ShouldStartWith("INFO "); await cts.CancelAsync(); } /// /// A server created with TraceVerbose=true implies Trace=true when processed /// via ConfigProcessor. The option pair follows the Go server's precedence rules. /// Go reference: opts.go — if TraceVerbose then Trace=true /// [Fact] public void NatsOptions_trace_verbose_can_be_set_independently() { var opts = new NatsOptions { TraceVerbose = true }; // TraceVerbose is stored independently; it's up to ConfigProcessor to // cascade Trace=true. Verify the field is stored as set. opts.TraceVerbose.ShouldBeTrue(); } // ------------------------------------------------------------------------- // ClientFlags.TraceMode // Reference: msgtrace_test.go — per-client trace mode from server-level trace // ------------------------------------------------------------------------- /// /// ClientFlagHolder.HasFlag returns false for TraceMode initially. A fresh /// client has no trace mode set. /// Go reference: client.go — clientFlag trace bit initialised to zero /// [Fact] public void ClientFlagHolder_trace_mode_is_not_set_by_default() { var holder = new ClientFlagHolder(); holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse(); } /// /// ClientFlagHolder.SetFlag / ClearFlag toggle TraceMode correctly. /// Go reference: client.go setTraceMode /// [Fact] public void ClientFlagHolder_set_and_clear_trace_mode() { 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 — toggling it does not affect /// ConnectReceived or other status bits. /// Go reference: client.go — per-bit flag isolation /// [Fact] public void ClientFlagHolder_trace_mode_does_not_affect_other_flags() { 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(); } }