using System.Text; using System.Text.Json; using NATS.Server.Events; using NATS.Server.Internal; namespace NATS.Server.Core.Tests.Internal; /// /// Tests for MsgTraceContext: header parsing, event collection, trace propagation, /// JetStream two-phase send, hop tracking, and JSON serialization. /// Go reference: msgtrace.go — initMsgTrace, sendEvent, addEgressEvent, /// addJetStreamEvent, genHeaderMapIfTraceHeadersPresent. /// public class MessageTraceContextTests { private static ReadOnlyMemory BuildHeaders(params (string key, string value)[] headers) { var sb = new StringBuilder("NATS/1.0\r\n"); foreach (var (key, value) in headers) { sb.Append($"{key}: {value}\r\n"); } sb.Append("\r\n"); return Encoding.ASCII.GetBytes(sb.ToString()); } // --- Header parsing --- [Fact] public void ParseTraceHeaders_returns_null_for_no_trace_headers() { var headers = BuildHeaders(("Content-Type", "text/plain")); var result = MsgTraceContext.ParseTraceHeaders(headers.Span); result.ShouldBeNull(); } [Fact] public void ParseTraceHeaders_returns_map_when_trace_dest_present() { var headers = BuildHeaders( (MsgTraceHeaders.TraceDest, "trace.subject"), ("Content-Type", "text/plain")); var result = MsgTraceContext.ParseTraceHeaders(headers.Span); result.ShouldNotBeNull(); result.ShouldContainKey(MsgTraceHeaders.TraceDest); result[MsgTraceHeaders.TraceDest][0].ShouldBe("trace.subject"); } [Fact] public void ParseTraceHeaders_returns_null_when_trace_disabled() { var headers = BuildHeaders( (MsgTraceHeaders.TraceDest, MsgTraceHeaders.TraceDestDisabled)); var result = MsgTraceContext.ParseTraceHeaders(headers.Span); result.ShouldBeNull(); } [Fact] public void ParseTraceHeaders_detects_traceparent_with_sampled_flag() { // W3C trace context: version-traceid-parentid-flags (01 = sampled) var headers = BuildHeaders( ("traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")); var result = MsgTraceContext.ParseTraceHeaders(headers.Span); result.ShouldNotBeNull(); result.ShouldContainKey("traceparent"); } [Fact] public void ParseTraceHeaders_ignores_traceparent_without_sampled_flag() { // flags=00 means not sampled var headers = BuildHeaders( ("traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00")); var result = MsgTraceContext.ParseTraceHeaders(headers.Span); result.ShouldBeNull(); } [Fact] public void ParseTraceHeaders_returns_null_for_empty_input() { var result = MsgTraceContext.ParseTraceHeaders(ReadOnlySpan.Empty); result.ShouldBeNull(); } [Fact] public void ParseTraceHeaders_returns_null_for_non_nats_header() { var headers = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nFoo: bar\r\n\r\n"); var result = MsgTraceContext.ParseTraceHeaders(headers); result.ShouldBeNull(); } // --- Context creation --- [Fact] public void Create_returns_null_for_empty_headers() { var ctx = MsgTraceContext.Create( ReadOnlyMemory.Empty, clientId: 1, clientName: "test", accountName: "$G", subject: "test.sub", msgSize: 10); ctx.ShouldBeNull(); } [Fact] public void Create_returns_null_for_headers_without_trace() { var headers = BuildHeaders(("Content-Type", "text/plain")); var ctx = MsgTraceContext.Create( headers, clientId: 1, clientName: "test", accountName: "$G", subject: "test.sub", msgSize: 10); ctx.ShouldBeNull(); } [Fact] public void Create_builds_context_with_ingress_event() { var headers = BuildHeaders( (MsgTraceHeaders.TraceDest, "trace.dest")); var ctx = MsgTraceContext.Create( headers, clientId: 42, clientName: "my-publisher", accountName: "$G", subject: "orders.new", msgSize: 128); ctx.ShouldNotBeNull(); ctx.IsActive.ShouldBeTrue(); ctx.Destination.ShouldBe("trace.dest"); ctx.TraceOnly.ShouldBeFalse(); ctx.AccountName.ShouldBe("$G"); // Check ingress event ctx.Event.Events.Count.ShouldBe(1); var ingress = ctx.Event.Events[0].ShouldBeOfType(); ingress.Type.ShouldBe(MsgTraceTypes.Ingress); ingress.Cid.ShouldBe(42UL); ingress.Name.ShouldBe("my-publisher"); ingress.Account.ShouldBe("$G"); ingress.Subject.ShouldBe("orders.new"); ingress.Error.ShouldBeNull(); // Check request info ctx.Event.Request.MsgSize.ShouldBe(128); ctx.Event.Request.Header.ShouldNotBeNull(); ctx.Event.Request.Header.ShouldContainKey(MsgTraceHeaders.TraceDest); } [Fact] public void Create_with_trace_only_flag() { var headers = BuildHeaders( (MsgTraceHeaders.TraceDest, "trace.dest"), (MsgTraceHeaders.TraceOnly, "true")); var ctx = MsgTraceContext.Create( headers, clientId: 1, clientName: "test", accountName: "$G", subject: "test", msgSize: 0); ctx.ShouldNotBeNull(); ctx.TraceOnly.ShouldBeTrue(); } [Fact] public void Create_with_trace_only_flag_numeric() { var headers = BuildHeaders( (MsgTraceHeaders.TraceDest, "trace.dest"), (MsgTraceHeaders.TraceOnly, "1")); var ctx = MsgTraceContext.Create( headers, clientId: 1, clientName: "test", accountName: "$G", subject: "test", msgSize: 0); ctx.ShouldNotBeNull(); ctx.TraceOnly.ShouldBeTrue(); } [Fact] public void Create_without_trace_only_flag() { var headers = BuildHeaders( (MsgTraceHeaders.TraceDest, "trace.dest"), (MsgTraceHeaders.TraceOnly, "false")); var ctx = MsgTraceContext.Create( headers, clientId: 1, clientName: "test", accountName: "$G", subject: "test", msgSize: 0); ctx.ShouldNotBeNull(); ctx.TraceOnly.ShouldBeFalse(); } [Fact] public void Create_captures_hop_from_non_client_kind() { var headers = BuildHeaders( (MsgTraceHeaders.TraceDest, "trace.dest"), (MsgTraceHeaders.TraceHop, "1.2")); var ctx = MsgTraceContext.Create( headers, clientId: 1, clientName: "route-1", accountName: "$G", subject: "test", msgSize: 0, clientKind: MsgTraceContext.KindRouter); ctx.ShouldNotBeNull(); ctx.Hop.ShouldBe("1.2"); } [Fact] public void Create_ignores_hop_from_client_kind() { var headers = BuildHeaders( (MsgTraceHeaders.TraceDest, "trace.dest"), (MsgTraceHeaders.TraceHop, "1.2")); var ctx = MsgTraceContext.Create( headers, clientId: 1, clientName: "test", accountName: "$G", subject: "test", msgSize: 0, clientKind: MsgTraceContext.KindClient); ctx.ShouldNotBeNull(); ctx.Hop.ShouldBe(""); // Client hop is ignored } // --- Event recording --- [Fact] public void SetIngressError_sets_error_on_first_event() { var ctx = CreateSimpleContext(); ctx.SetIngressError("publish denied"); var ingress = ctx.Event.Events[0].ShouldBeOfType(); ingress.Error.ShouldBe("publish denied"); } [Fact] public void AddSubjectMappingEvent_appends_mapping() { var ctx = CreateSimpleContext(); ctx.AddSubjectMappingEvent("orders.mapped"); ctx.Event.Events.Count.ShouldBe(2); var mapping = ctx.Event.Events[1].ShouldBeOfType(); mapping.Type.ShouldBe(MsgTraceTypes.SubjectMapping); mapping.MappedTo.ShouldBe("orders.mapped"); } [Fact] public void AddEgressEvent_appends_egress_with_subscription_and_queue() { var ctx = CreateSimpleContext(); ctx.AddEgressEvent( clientId: 99, clientName: "subscriber", clientKind: MsgTraceContext.KindClient, subscriptionSubject: "orders.>", queue: "workers"); ctx.Event.Events.Count.ShouldBe(2); var egress = ctx.Event.Events[1].ShouldBeOfType(); egress.Type.ShouldBe(MsgTraceTypes.Egress); egress.Kind.ShouldBe(MsgTraceContext.KindClient); egress.Cid.ShouldBe(99UL); egress.Name.ShouldBe("subscriber"); egress.Subscription.ShouldBe("orders.>"); egress.Queue.ShouldBe("workers"); } [Fact] public void AddEgressEvent_records_account_when_different_from_ingress() { var ctx = CreateSimpleContext(accountName: "acctA"); ctx.AddEgressEvent( clientId: 99, clientName: "subscriber", clientKind: MsgTraceContext.KindClient, subscriptionSubject: "api.>", account: "acctB"); var egress = ctx.Event.Events[1].ShouldBeOfType(); egress.Account.ShouldBe("acctB"); } [Fact] public void AddEgressEvent_omits_account_when_same_as_ingress() { var ctx = CreateSimpleContext(accountName: "$G"); ctx.AddEgressEvent( clientId: 99, clientName: "subscriber", clientKind: MsgTraceContext.KindClient, subscriptionSubject: "test", account: "$G"); var egress = ctx.Event.Events[1].ShouldBeOfType(); egress.Account.ShouldBeNull(); } [Fact] public void AddEgressEvent_for_router_omits_subscription_and_queue() { var ctx = CreateSimpleContext(); ctx.AddEgressEvent( clientId: 1, clientName: "route-1", clientKind: MsgTraceContext.KindRouter, subscriptionSubject: "should.not.appear", queue: "should.not.appear"); var egress = ctx.Event.Events[1].ShouldBeOfType(); egress.Subscription.ShouldBeNull(); egress.Queue.ShouldBeNull(); } [Fact] public void AddEgressEvent_with_error() { var ctx = CreateSimpleContext(); ctx.AddEgressEvent( clientId: 50, clientName: "slow-client", clientKind: MsgTraceContext.KindClient, error: MsgTraceErrors.ClientClosed); var egress = ctx.Event.Events[1].ShouldBeOfType(); egress.Error.ShouldBe(MsgTraceErrors.ClientClosed); } [Fact] public void AddStreamExportEvent_records_account_and_target() { var ctx = CreateSimpleContext(); ctx.AddStreamExportEvent("exportAccount", "export.subject"); ctx.Event.Events.Count.ShouldBe(2); var se = ctx.Event.Events[1].ShouldBeOfType(); se.Type.ShouldBe(MsgTraceTypes.StreamExport); se.Account.ShouldBe("exportAccount"); se.To.ShouldBe("export.subject"); } [Fact] public void AddServiceImportEvent_records_from_and_to() { var ctx = CreateSimpleContext(); ctx.AddServiceImportEvent("importAccount", "from.subject", "to.subject"); ctx.Event.Events.Count.ShouldBe(2); var si = ctx.Event.Events[1].ShouldBeOfType(); si.Type.ShouldBe(MsgTraceTypes.ServiceImport); si.Account.ShouldBe("importAccount"); si.From.ShouldBe("from.subject"); si.To.ShouldBe("to.subject"); } // --- JetStream events --- [Fact] public void AddJetStreamEvent_records_stream_name() { var ctx = CreateSimpleContext(); ctx.AddJetStreamEvent("ORDERS"); ctx.Event.Events.Count.ShouldBe(2); var js = ctx.Event.Events[1].ShouldBeOfType(); js.Type.ShouldBe(MsgTraceTypes.JetStream); js.Stream.ShouldBe("ORDERS"); } [Fact] public void UpdateJetStreamEvent_sets_subject_and_nointerest() { var ctx = CreateSimpleContext(); ctx.AddJetStreamEvent("ORDERS"); ctx.UpdateJetStreamEvent("orders.new", noInterest: true); var js = ctx.Event.Events[1].ShouldBeOfType(); js.Subject.ShouldBe("orders.new"); js.NoInterest.ShouldBeTrue(); } [Fact] public void SendEventFromJetStream_requires_both_phases() { var ctx = CreateSimpleContext(); ctx.AddJetStreamEvent("ORDERS"); bool published = false; ctx.PublishCallback = (dest, reply, body) => { published = true; }; // Phase 1: message path calls SendEvent — should not publish yet ctx.SendEvent(); published.ShouldBeFalse(); // Phase 2: JetStream path calls SendEventFromJetStream — now publishes ctx.SendEventFromJetStream(); published.ShouldBeTrue(); } [Fact] public void SendEventFromJetStream_with_error() { var ctx = CreateSimpleContext(); ctx.AddJetStreamEvent("ORDERS"); object? publishedBody = null; ctx.PublishCallback = (dest, reply, body) => { publishedBody = body; }; ctx.SendEvent(); // Phase 1 ctx.SendEventFromJetStream("stream full"); // Phase 2 publishedBody.ShouldNotBeNull(); var js = ctx.Event.Events[1].ShouldBeOfType(); js.Error.ShouldBe("stream full"); } // --- Hop tracking --- [Fact] public void SetHopHeader_increments_and_builds_hop_id() { var ctx = CreateSimpleContext(); ctx.SetHopHeader(); ctx.Event.Hops.ShouldBe(1); ctx.NextHop.ShouldBe("1"); ctx.SetHopHeader(); ctx.Event.Hops.ShouldBe(2); ctx.NextHop.ShouldBe("2"); } [Fact] public void SetHopHeader_chains_from_existing_hop() { var headers = BuildHeaders( (MsgTraceHeaders.TraceDest, "trace.dest"), (MsgTraceHeaders.TraceHop, "1")); var ctx = MsgTraceContext.Create( headers, clientId: 1, clientName: "router", accountName: "$G", subject: "test", msgSize: 0, clientKind: MsgTraceContext.KindRouter); ctx.ShouldNotBeNull(); ctx.Hop.ShouldBe("1"); ctx.SetHopHeader(); ctx.NextHop.ShouldBe("1.1"); ctx.SetHopHeader(); ctx.NextHop.ShouldBe("1.2"); } [Fact] public void AddEgressEvent_captures_and_clears_next_hop() { var ctx = CreateSimpleContext(); ctx.SetHopHeader(); ctx.NextHop.ShouldBe("1"); ctx.AddEgressEvent(1, "route-1", MsgTraceContext.KindRouter); var egress = ctx.Event.Events[1].ShouldBeOfType(); egress.Hop.ShouldBe("1"); // NextHop should be cleared after adding egress ctx.NextHop.ShouldBe(""); } // --- SendEvent (non-JetStream) --- [Fact] public void SendEvent_publishes_immediately_without_jetstream() { var ctx = CreateSimpleContext(); string? publishedDest = null; ctx.PublishCallback = (dest, reply, body) => { publishedDest = dest; }; ctx.SendEvent(); publishedDest.ShouldBe("trace.dest"); } // --- JSON serialization --- [Fact] public void MsgTraceEvent_serializes_to_valid_json() { var ctx = CreateSimpleContext(); ctx.Event.Server = new EventServerInfo { Name = "srv", Id = "SRV1" }; ctx.AddSubjectMappingEvent("mapped.subject"); ctx.AddEgressEvent(99, "subscriber", MsgTraceContext.KindClient, "test.>", "q1"); ctx.AddStreamExportEvent("exportAcc", "export.subject"); var json = JsonSerializer.Serialize(ctx.Event); var doc = JsonDocument.Parse(json); var root = doc.RootElement; root.GetProperty("server").GetProperty("name").GetString().ShouldBe("srv"); root.GetProperty("request").GetProperty("msgsize").GetInt32().ShouldBe(64); root.GetProperty("events").GetArrayLength().ShouldBe(4); var events = root.GetProperty("events"); events[0].GetProperty("type").GetString().ShouldBe(MsgTraceTypes.Ingress); events[1].GetProperty("type").GetString().ShouldBe(MsgTraceTypes.SubjectMapping); events[2].GetProperty("type").GetString().ShouldBe(MsgTraceTypes.Egress); events[3].GetProperty("type").GetString().ShouldBe(MsgTraceTypes.StreamExport); } [Fact] public void MsgTraceIngress_json_omits_null_error() { var ingress = new MsgTraceIngress { Type = MsgTraceTypes.Ingress, Cid = 1, Account = "$G", Subject = "test", }; var json = JsonSerializer.Serialize(ingress); var doc = JsonDocument.Parse(json); doc.RootElement.TryGetProperty("error", out _).ShouldBeFalse(); } [Fact] public void MsgTraceEgress_json_omits_null_optional_fields() { var egress = new MsgTraceEgress { Type = MsgTraceTypes.Egress, Kind = MsgTraceContext.KindRouter, Cid = 5, }; var json = JsonSerializer.Serialize(egress); var doc = JsonDocument.Parse(json); var root = doc.RootElement; root.TryGetProperty("hop", out _).ShouldBeFalse(); root.TryGetProperty("acc", out _).ShouldBeFalse(); root.TryGetProperty("sub", out _).ShouldBeFalse(); root.TryGetProperty("queue", out _).ShouldBeFalse(); root.TryGetProperty("error", out _).ShouldBeFalse(); } [Fact] public void Full_trace_event_with_all_event_types_serializes_correctly() { var ctx = CreateSimpleContext(); ctx.Event.Server = new EventServerInfo { Name = "test-srv", Id = "ABC123" }; ctx.AddSubjectMappingEvent("mapped"); ctx.AddServiceImportEvent("importAcc", "from.sub", "to.sub"); ctx.AddStreamExportEvent("exportAcc", "export.sub"); ctx.AddJetStreamEvent("ORDERS"); ctx.UpdateJetStreamEvent("orders.new", false); ctx.AddEgressEvent(100, "sub-1", MsgTraceContext.KindClient, "orders.>", "workers"); ctx.AddEgressEvent(200, "route-east", MsgTraceContext.KindRouter, error: MsgTraceErrors.NoSupport); var json = JsonSerializer.Serialize(ctx.Event); var doc = JsonDocument.Parse(json); var events = doc.RootElement.GetProperty("events"); events.GetArrayLength().ShouldBe(7); events[0].GetProperty("type").GetString().ShouldBe("in"); events[1].GetProperty("type").GetString().ShouldBe("sm"); events[2].GetProperty("type").GetString().ShouldBe("si"); events[3].GetProperty("type").GetString().ShouldBe("se"); events[4].GetProperty("type").GetString().ShouldBe("js"); events[5].GetProperty("type").GetString().ShouldBe("eg"); events[6].GetProperty("type").GetString().ShouldBe("eg"); } // --- Helper --- private static MsgTraceContext CreateSimpleContext(string destination = "trace.dest", string accountName = "$G") { var headers = BuildHeaders( (MsgTraceHeaders.TraceDest, destination)); var ctx = MsgTraceContext.Create( headers, clientId: 1, clientName: "publisher", accountName: accountName, subject: "test.subject", msgSize: 64); ctx.ShouldNotBeNull(); return ctx; } }