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