refactor: rename remaining tests to NATS.Server.Core.Tests
- 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
This commit is contained in:
24
tests/NATS.Server.Core.Tests/ClientClosedReasonTests.cs
Normal file
24
tests/NATS.Server.Core.Tests/ClientClosedReasonTests.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ClientClosedReasonTests
|
||||
{
|
||||
[Fact]
|
||||
public void All_expected_close_reasons_exist()
|
||||
{
|
||||
// Verify all 18 enum values exist and are distinct (None + 17 named reasons)
|
||||
var values = Enum.GetValues<ClientClosedReason>();
|
||||
values.Length.ShouldBe(18);
|
||||
values.Distinct().Count().ShouldBe(18);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ClientClosedReason.ClientClosed, "Client Closed")]
|
||||
[InlineData(ClientClosedReason.SlowConsumerPendingBytes, "Slow Consumer (Pending Bytes)")]
|
||||
[InlineData(ClientClosedReason.SlowConsumerWriteDeadline, "Slow Consumer (Write Deadline)")]
|
||||
[InlineData(ClientClosedReason.StaleConnection, "Stale Connection")]
|
||||
[InlineData(ClientClosedReason.ServerShutdown, "Server Shutdown")]
|
||||
public void ToReasonString_returns_human_readable_description(ClientClosedReason reason, string expected)
|
||||
{
|
||||
reason.ToReasonString().ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
53
tests/NATS.Server.Core.Tests/ClientFlagsTests.cs
Normal file
53
tests/NATS.Server.Core.Tests/ClientFlagsTests.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ClientFlagsTests
|
||||
{
|
||||
[Fact]
|
||||
public void SetFlag_and_HasFlag_work()
|
||||
{
|
||||
var holder = new ClientFlagHolder();
|
||||
holder.HasFlag(ClientFlags.ConnectReceived).ShouldBeFalse();
|
||||
|
||||
holder.SetFlag(ClientFlags.ConnectReceived);
|
||||
holder.HasFlag(ClientFlags.ConnectReceived).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearFlag_removes_flag()
|
||||
{
|
||||
var holder = new ClientFlagHolder();
|
||||
holder.SetFlag(ClientFlags.ConnectReceived);
|
||||
holder.SetFlag(ClientFlags.IsSlowConsumer);
|
||||
|
||||
holder.ClearFlag(ClientFlags.ConnectReceived);
|
||||
|
||||
holder.HasFlag(ClientFlags.ConnectReceived).ShouldBeFalse();
|
||||
holder.HasFlag(ClientFlags.IsSlowConsumer).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_flags_can_be_set_independently()
|
||||
{
|
||||
var holder = new ClientFlagHolder();
|
||||
holder.SetFlag(ClientFlags.ConnectReceived);
|
||||
holder.SetFlag(ClientFlags.WriteLoopStarted);
|
||||
holder.SetFlag(ClientFlags.FirstPongSent);
|
||||
|
||||
holder.HasFlag(ClientFlags.ConnectReceived).ShouldBeTrue();
|
||||
holder.HasFlag(ClientFlags.WriteLoopStarted).ShouldBeTrue();
|
||||
holder.HasFlag(ClientFlags.FirstPongSent).ShouldBeTrue();
|
||||
holder.HasFlag(ClientFlags.IsSlowConsumer).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetFlag_is_thread_safe()
|
||||
{
|
||||
var holder = new ClientFlagHolder();
|
||||
var flags = Enum.GetValues<ClientFlags>();
|
||||
|
||||
Parallel.ForEach(flags, flag => holder.SetFlag(flag));
|
||||
|
||||
foreach (var flag in flags)
|
||||
holder.HasFlag(flag).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
179
tests/NATS.Server.Core.Tests/ClientHeaderTests.cs
Normal file
179
tests/NATS.Server.Core.Tests/ClientHeaderTests.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
// Reference: golang/nats-server/server/client_test.go — TestClientHeaderDeliverMsg,
|
||||
// TestServerHeaderSupport, TestClientHeaderSupport
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for HPUB/HMSG header support, mirroring the Go reference tests:
|
||||
/// TestClientHeaderDeliverMsg, TestServerHeaderSupport, TestClientHeaderSupport.
|
||||
///
|
||||
/// Go reference: golang/nats-server/server/client_test.go:259–368
|
||||
/// </summary>
|
||||
public class ClientHeaderTests : IAsyncLifetime
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public ClientHeaderTests()
|
||||
{
|
||||
_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();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Reads from the socket accumulating data until the accumulated string contains
|
||||
/// <paramref name="expected"/>, or the timeout elapses.
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Connect a raw TCP socket, read the INFO line, and send a CONNECT with
|
||||
/// headers:true and no_responders:true.
|
||||
/// </summary>
|
||||
private async Task<Socket> ConnectWithHeadersAsync()
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, _port);
|
||||
await SocketTestHelper.ReadUntilAsync(sock, "\r\n"); // discard INFO
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes(
|
||||
"CONNECT {\"headers\":true,\"no_responders\":true}\r\n"));
|
||||
return sock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of TestClientHeaderDeliverMsg (client_test.go:330).
|
||||
///
|
||||
/// A client that advertises headers:true sends an HPUB message with a custom
|
||||
/// header block. A subscriber should receive the message as HMSG with the
|
||||
/// header block and payload intact.
|
||||
///
|
||||
/// HPUB format: HPUB subject hdr_len total_len\r\n{headers}{payload}\r\n
|
||||
/// HMSG format: HMSG subject sid hdr_len total_len\r\n{headers}{payload}\r\n
|
||||
///
|
||||
/// Matches Go reference: HPUB foo 12 14\r\nName:Derek\r\nOK\r\n
|
||||
/// hdrLen=12 ("Name:Derek\r\n"), totalLen=14 (headers + "OK")
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Hpub_delivers_hmsg_with_headers()
|
||||
{
|
||||
// Use two separate connections: subscriber and publisher.
|
||||
// The Go reference uses a single connection for both, but two connections
|
||||
// make the test clearer and avoid echo-suppression edge cases.
|
||||
using var sub = await ConnectWithHeadersAsync();
|
||||
using var pub = await ConnectWithHeadersAsync();
|
||||
|
||||
// Subscribe on 'foo' with SID 1
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\n"));
|
||||
// Flush via PING/PONG to ensure the subscription is registered before publishing
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
|
||||
|
||||
// Match Go reference test exactly:
|
||||
// Header block: "Name:Derek\r\n" = 12 bytes
|
||||
// Payload: "OK" = 2 bytes → total = 14 bytes
|
||||
const string headerBlock = "Name:Derek\r\n";
|
||||
const string payload = "OK";
|
||||
const int hdrLen = 12; // "Name:Derek\r\n"
|
||||
const int totalLen = 14; // hdrLen + "OK"
|
||||
|
||||
var hpub = $"HPUB foo {hdrLen} {totalLen}\r\n{headerBlock}{payload}\r\n";
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(hpub));
|
||||
|
||||
// Read the full HMSG on the subscriber socket (control line + header + payload + trailing CRLF)
|
||||
// The complete wire message ends with the payload followed by \r\n
|
||||
var received = await SocketTestHelper.ReadUntilAsync(sub, payload + "\r\n", timeoutMs: 5000);
|
||||
|
||||
// Verify HMSG control line: HMSG foo 1 <hdrLen> <totalLen>
|
||||
received.ShouldContain($"HMSG foo 1 {hdrLen} {totalLen}\r\n");
|
||||
// Verify the header block is delivered verbatim
|
||||
received.ShouldContain("Name:Derek");
|
||||
// Verify the payload is delivered
|
||||
received.ShouldContain(payload);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of TestServerHeaderSupport (client_test.go:259).
|
||||
///
|
||||
/// By default the server advertises "headers":true in the INFO response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Server_info_advertises_headers_true()
|
||||
{
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, _port);
|
||||
|
||||
// Read the INFO line
|
||||
var infoLine = await SocketTestHelper.ReadUntilAsync(sock, "\r\n");
|
||||
|
||||
// INFO must start with "INFO "
|
||||
infoLine.ShouldStartWith("INFO ");
|
||||
|
||||
// Extract the JSON blob after "INFO "
|
||||
var jsonStart = infoLine.IndexOf('{');
|
||||
var jsonEnd = infoLine.LastIndexOf('}');
|
||||
jsonStart.ShouldBeGreaterThanOrEqualTo(0);
|
||||
jsonEnd.ShouldBeGreaterThan(jsonStart);
|
||||
|
||||
var json = infoLine[jsonStart..(jsonEnd + 1)];
|
||||
|
||||
// The JSON must contain "headers":true
|
||||
json.ShouldContain("\"headers\":true");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of TestClientNoResponderSupport (client_test.go:230) — specifically
|
||||
/// the branch that sends a PUB to a subject with no subscribers when the
|
||||
/// client has opted in with headers:true + no_responders:true.
|
||||
///
|
||||
/// The server must send an HMSG on the reply subject with the 503 status
|
||||
/// header "NATS/1.0 503\r\n\r\n".
|
||||
///
|
||||
/// Wire sequence:
|
||||
/// Client → CONNECT {headers:true, no_responders:true}
|
||||
/// Client → SUB reply.inbox 1
|
||||
/// Client → PUB no.listeners reply.inbox 0 (0-byte payload, no subscribers)
|
||||
/// Server → HMSG reply.inbox 1 {hdrLen} {hdrLen}\r\nNATS/1.0 503\r\n\r\n\r\n
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task No_responders_sends_503_hmsg_when_no_subscribers()
|
||||
{
|
||||
using var sock = await ConnectWithHeadersAsync();
|
||||
|
||||
// Subscribe to the reply inbox
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("SUB reply.inbox 1\r\n"));
|
||||
// Flush via PING/PONG to ensure SUB is registered
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sock, "PONG");
|
||||
|
||||
// Publish to a subject with no subscribers, using reply.inbox as reply-to
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("PUB no.listeners reply.inbox 0\r\n\r\n"));
|
||||
|
||||
// The server should send back an HMSG on reply.inbox with status 503
|
||||
var received = await SocketTestHelper.ReadUntilAsync(sock, "NATS/1.0 503", timeoutMs: 5000);
|
||||
|
||||
// Must be an HMSG (header message) on the reply subject
|
||||
received.ShouldContain("HMSG reply.inbox");
|
||||
// Must carry the 503 status header
|
||||
received.ShouldContain("NATS/1.0 503");
|
||||
}
|
||||
}
|
||||
14
tests/NATS.Server.Core.Tests/ClientKindCommandMatrixTests.cs
Normal file
14
tests/NATS.Server.Core.Tests/ClientKindCommandMatrixTests.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ClientKindCommandMatrixTests
|
||||
{
|
||||
[Fact]
|
||||
public void Router_only_commands_are_rejected_for_client_kind()
|
||||
{
|
||||
var matrix = new ClientCommandMatrix();
|
||||
matrix.IsAllowed(ClientKind.Client, "RS+").ShouldBeFalse();
|
||||
matrix.IsAllowed(ClientKind.Router, "RS+").ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ClientKindProtocolRoutingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ClientKind.Client, "RS+", false)]
|
||||
[InlineData(ClientKind.Router, "RS+", true)]
|
||||
[InlineData(ClientKind.Client, "RS-", false)]
|
||||
[InlineData(ClientKind.Router, "RS-", true)]
|
||||
[InlineData(ClientKind.Client, "RMSG", false)]
|
||||
[InlineData(ClientKind.Router, "RMSG", true)]
|
||||
[InlineData(ClientKind.Client, "A+", false)]
|
||||
[InlineData(ClientKind.Gateway, "A+", true)]
|
||||
[InlineData(ClientKind.Client, "A-", false)]
|
||||
[InlineData(ClientKind.Gateway, "A-", true)]
|
||||
[InlineData(ClientKind.Client, "LS+", false)]
|
||||
[InlineData(ClientKind.Leaf, "LS+", true)]
|
||||
[InlineData(ClientKind.Client, "LS-", false)]
|
||||
[InlineData(ClientKind.Leaf, "LS-", true)]
|
||||
[InlineData(ClientKind.Client, "LMSG", false)]
|
||||
[InlineData(ClientKind.Leaf, "LMSG", true)]
|
||||
public void Client_kind_protocol_matrix_enforces_inter_server_commands(ClientKind kind, string op, bool expected)
|
||||
{
|
||||
var matrix = new ClientCommandMatrix();
|
||||
matrix.IsAllowed(kind, op).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
50
tests/NATS.Server.Core.Tests/ClientKindTests.cs
Normal file
50
tests/NATS.Server.Core.Tests/ClientKindTests.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
// Tests for ClientKind enum and IsInternal() extension method.
|
||||
// Go reference: client.go:45-65 (client kind constants and isInternal check)
|
||||
|
||||
using NATS.Server;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ClientKindTests
|
||||
{
|
||||
[Fact]
|
||||
public void Client_is_not_internal() =>
|
||||
ClientKind.Client.IsInternal().ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Router_is_not_internal() =>
|
||||
ClientKind.Router.IsInternal().ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Gateway_is_not_internal() =>
|
||||
ClientKind.Gateway.IsInternal().ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Leaf_is_not_internal() =>
|
||||
ClientKind.Leaf.IsInternal().ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void System_is_internal() =>
|
||||
ClientKind.System.IsInternal().ShouldBeTrue();
|
||||
|
||||
[Fact]
|
||||
public void JetStream_is_internal() =>
|
||||
ClientKind.JetStream.IsInternal().ShouldBeTrue();
|
||||
|
||||
[Fact]
|
||||
public void Account_is_internal() =>
|
||||
ClientKind.Account.IsInternal().ShouldBeTrue();
|
||||
|
||||
[Fact]
|
||||
public void All_kinds_defined() =>
|
||||
Enum.GetValues<ClientKind>().Length.ShouldBe(7);
|
||||
|
||||
[Fact]
|
||||
public void Internal_kinds_count_is_three() =>
|
||||
Enum.GetValues<ClientKind>().Count(k => k.IsInternal()).ShouldBe(3);
|
||||
|
||||
[Fact]
|
||||
public void External_kinds_count_is_four() =>
|
||||
Enum.GetValues<ClientKind>().Count(k => !k.IsInternal()).ShouldBe(4);
|
||||
}
|
||||
169
tests/NATS.Server.Core.Tests/ClientLifecycleTests.cs
Normal file
169
tests/NATS.Server.Core.Tests/ClientLifecycleTests.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
// Port of Go client_test.go: TestClientConnect, TestClientConnectProto, TestAuthorizationTimeout
|
||||
// Reference: golang/nats-server/server/client_test.go lines 475, 537, 1260
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for client lifecycle: connection handshake, CONNECT proto parsing,
|
||||
/// subscription limits, and auth timeout enforcement.
|
||||
/// Reference: Go TestClientConnect, TestClientConnectProto, TestAuthorizationTimeout
|
||||
/// </summary>
|
||||
public class ClientLifecycleTests
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// TestClientConnectProto: Sends CONNECT with verbose:false, pedantic:false, name:"test-client"
|
||||
/// and verifies the server responds with PONG, confirming the connection is accepted.
|
||||
/// Reference: Go client_test.go TestClientConnectProto (line 537)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Connect_proto_accepted()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client.ConnectAsync(IPAddress.Loopback, port);
|
||||
|
||||
// Read INFO
|
||||
var buf = new byte[4096];
|
||||
var n = await client.ReceiveAsync(buf, SocketFlags.None);
|
||||
var info = Encoding.ASCII.GetString(buf, 0, n);
|
||||
info.ShouldStartWith("INFO ");
|
||||
|
||||
// Send CONNECT with client name, then PING to flush
|
||||
var connectMsg = """CONNECT {"verbose":false,"pedantic":false,"name":"test-client"}""" + "\r\nPING\r\n";
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes(connectMsg));
|
||||
|
||||
// Should receive PONG confirming connection is accepted
|
||||
var response = await SocketTestHelper.ReadUntilAsync(client, "PONG");
|
||||
response.ShouldContain("PONG\r\n");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Max_subscriptions_enforced: Creates a server with MaxSubs=10, subscribes 10 times,
|
||||
/// then verifies that the 11th SUB triggers a -ERR 'Maximum Subscriptions Exceeded'
|
||||
/// and the connection is closed.
|
||||
/// Reference: Go client_test.go — MaxSubs enforcement in NatsClient.cs line 527
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Max_subscriptions_enforced()
|
||||
{
|
||||
const int maxSubs = 10;
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port, MaxSubs = maxSubs },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client.ConnectAsync(IPAddress.Loopback, port);
|
||||
|
||||
// Read INFO
|
||||
var buf = new byte[4096];
|
||||
await client.ReceiveAsync(buf, SocketFlags.None);
|
||||
|
||||
// Send CONNECT
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n"));
|
||||
|
||||
// Subscribe up to the limit
|
||||
var subsBuilder = new StringBuilder();
|
||||
for (int i = 1; i <= maxSubs; i++)
|
||||
{
|
||||
subsBuilder.Append($"SUB foo.{i} {i}\r\n");
|
||||
}
|
||||
// Send the 11th subscription (one over the limit)
|
||||
subsBuilder.Append($"SUB foo.overflow {maxSubs + 1}\r\n");
|
||||
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes(subsBuilder.ToString()));
|
||||
|
||||
// Server should send -ERR 'Maximum Subscriptions Exceeded' and close
|
||||
var response = await SocketTestHelper.ReadUntilAsync(client, "-ERR", timeoutMs: 5000);
|
||||
response.ShouldContain("-ERR 'Maximum Subscriptions Exceeded'");
|
||||
|
||||
// Connection should be closed after the error
|
||||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
var n = await client.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
n.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auth_timeout_closes_connection_if_no_connect: Creates a server with auth
|
||||
/// (token-based) and a short AuthTimeout of 500ms. Connects a raw socket,
|
||||
/// reads INFO, but does NOT send CONNECT. Verifies the server closes the
|
||||
/// connection with -ERR 'Authentication Timeout' after the timeout expires.
|
||||
/// Reference: Go client_test.go TestAuthorizationTimeout (line 1260)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Auth_timeout_closes_connection_if_no_connect()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
Port = port,
|
||||
Authorization = "my_secret_token",
|
||||
AuthTimeout = TimeSpan.FromMilliseconds(500),
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client.ConnectAsync(IPAddress.Loopback, port);
|
||||
|
||||
// Read INFO — server requires auth so INFO will have auth_required:true
|
||||
var buf = new byte[4096];
|
||||
var n = await client.ReceiveAsync(buf, SocketFlags.None);
|
||||
var info = Encoding.ASCII.GetString(buf, 0, n);
|
||||
info.ShouldStartWith("INFO ");
|
||||
|
||||
// Do NOT send CONNECT — wait for auth timeout to fire
|
||||
// AuthTimeout is 500ms; wait up to 3x that for the error
|
||||
var response = await SocketTestHelper.ReadUntilAsync(client, "Authentication Timeout", timeoutMs: 3000);
|
||||
response.ShouldContain("-ERR 'Authentication Timeout'");
|
||||
|
||||
// Connection should be closed after the auth timeout error
|
||||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
n = await client.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
n.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
2126
tests/NATS.Server.Core.Tests/ClientProtocolParityTests.cs
Normal file
2126
tests/NATS.Server.Core.Tests/ClientProtocolParityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
177
tests/NATS.Server.Core.Tests/ClientPubSubTests.cs
Normal file
177
tests/NATS.Server.Core.Tests/ClientPubSubTests.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
// Go reference: golang/nats-server/server/client_test.go
|
||||
// TestClientSimplePubSub (line 666), TestClientPubSubNoEcho (line 691),
|
||||
// TestClientSimplePubSubWithReply (line 712), TestClientNoBodyPubSubWithReply (line 740),
|
||||
// TestClientPubWithQueueSub (line 768)
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ClientPubSubTests : IAsyncLifetime
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public ClientPubSubTests()
|
||||
{
|
||||
_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<Socket> ConnectClientAsync()
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, _port);
|
||||
return sock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads from a socket until the accumulated data contains the expected substring.
|
||||
/// </summary>
|
||||
|
||||
// Go reference: TestClientSimplePubSub (client_test.go line 666)
|
||||
// SUB foo 1, PUB foo 5\r\nhello — subscriber receives MSG foo 1 5\r\nhello
|
||||
[Fact]
|
||||
public async Task Simple_pub_sub_delivers_message()
|
||||
{
|
||||
using var client = await ConnectClientAsync();
|
||||
|
||||
// Read INFO
|
||||
var buf = new byte[4096];
|
||||
await client.ReceiveAsync(buf, SocketFlags.None);
|
||||
|
||||
// CONNECT, SUB, PUB, then PING to flush delivery
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes(
|
||||
"CONNECT {}\r\nSUB foo 1\r\nPUB foo 5\r\nhello\r\nPING\r\n"));
|
||||
|
||||
// Read until we see the message payload (delivered before PONG)
|
||||
var response = await SocketTestHelper.ReadUntilAsync(client, "hello\r\n");
|
||||
|
||||
// MSG line: MSG foo 1 5\r\nhello\r\n
|
||||
response.ShouldContain("MSG foo 1 5\r\nhello\r\n");
|
||||
}
|
||||
|
||||
// Go reference: TestClientPubSubNoEcho (client_test.go line 691)
|
||||
// CONNECT {"echo":false} — publishing client does NOT receive its own messages
|
||||
[Fact]
|
||||
public async Task Pub_sub_no_echo_suppresses_own_messages()
|
||||
{
|
||||
using var client = await ConnectClientAsync();
|
||||
|
||||
// Read INFO
|
||||
var buf = new byte[4096];
|
||||
await client.ReceiveAsync(buf, SocketFlags.None);
|
||||
|
||||
// Connect with echo=false, then SUB+PUB on same connection, then PING
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes(
|
||||
"CONNECT {\"echo\":false}\r\nSUB foo 1\r\nPUB foo 5\r\nhello\r\nPING\r\n"));
|
||||
|
||||
// With echo=false the server must not deliver the message back to the publisher.
|
||||
// The first line we receive should be PONG, not MSG.
|
||||
var response = await SocketTestHelper.ReadUntilAsync(client, "PONG\r\n");
|
||||
|
||||
response.ShouldStartWith("PONG\r\n");
|
||||
response.ShouldNotContain("MSG");
|
||||
}
|
||||
|
||||
// Go reference: TestClientSimplePubSubWithReply (client_test.go line 712)
|
||||
// PUB foo bar 5\r\nhello — subscriber receives MSG foo 1 bar 5\r\nhello (reply subject included)
|
||||
[Fact]
|
||||
public async Task Pub_sub_with_reply_subject()
|
||||
{
|
||||
using var client = await ConnectClientAsync();
|
||||
|
||||
// Read INFO
|
||||
var buf = new byte[4096];
|
||||
await client.ReceiveAsync(buf, SocketFlags.None);
|
||||
|
||||
// PUB with reply subject "bar"
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes(
|
||||
"CONNECT {}\r\nSUB foo 1\r\nPUB foo bar 5\r\nhello\r\nPING\r\n"));
|
||||
|
||||
var response = await SocketTestHelper.ReadUntilAsync(client, "hello\r\n");
|
||||
|
||||
// MSG line must include the reply subject: MSG <subject> <sid> <reply> <#bytes>
|
||||
response.ShouldContain("MSG foo 1 bar 5\r\nhello\r\n");
|
||||
}
|
||||
|
||||
// Go reference: TestClientNoBodyPubSubWithReply (client_test.go line 740)
|
||||
// PUB foo bar 0\r\n\r\n — zero-byte payload with reply subject
|
||||
[Fact]
|
||||
public async Task Empty_body_pub_sub_with_reply()
|
||||
{
|
||||
using var client = await ConnectClientAsync();
|
||||
|
||||
// Read INFO
|
||||
var buf = new byte[4096];
|
||||
await client.ReceiveAsync(buf, SocketFlags.None);
|
||||
|
||||
// PUB with reply subject and zero-length body
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes(
|
||||
"CONNECT {}\r\nSUB foo 1\r\nPUB foo bar 0\r\n\r\nPING\r\n"));
|
||||
|
||||
// Read until PONG — MSG should arrive before PONG
|
||||
var response = await SocketTestHelper.ReadUntilAsync(client, "PONG\r\n");
|
||||
|
||||
// MSG line: MSG foo 1 bar 0\r\n\r\n (empty body, still CRLF terminated)
|
||||
response.ShouldContain("MSG foo 1 bar 0\r\n");
|
||||
}
|
||||
|
||||
// Go reference: TestClientPubWithQueueSub (client_test.go line 768)
|
||||
// Two queue subscribers in the same group on one connection — 100 publishes
|
||||
// distributed across both sids, each receiving at least 20 messages.
|
||||
[Fact]
|
||||
public async Task Queue_sub_distributes_messages()
|
||||
{
|
||||
const int num = 100;
|
||||
|
||||
using var client = await ConnectClientAsync();
|
||||
|
||||
// Read INFO
|
||||
var buf = new byte[4096];
|
||||
await client.ReceiveAsync(buf, SocketFlags.None);
|
||||
|
||||
// CONNECT, two queue subs with different sids, PING to confirm
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes(
|
||||
"CONNECT {}\r\nSUB foo g1 1\r\nSUB foo g1 2\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(client, "PONG\r\n");
|
||||
|
||||
// Publish 100 messages, then PING to flush all deliveries
|
||||
var pubSb = new StringBuilder();
|
||||
for (int i = 0; i < num; i++)
|
||||
pubSb.Append("PUB foo 5\r\nhello\r\n");
|
||||
pubSb.Append("PING\r\n");
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes(pubSb.ToString()));
|
||||
|
||||
// Read until PONG — all MSGs arrive before the PONG
|
||||
var response = await SocketTestHelper.ReadUntilAsync(client, "PONG\r\n");
|
||||
|
||||
// Count deliveries per sid
|
||||
var n1 = Regex.Matches(response, @"MSG foo 1 5").Count;
|
||||
var n2 = Regex.Matches(response, @"MSG foo 2 5").Count;
|
||||
|
||||
(n1 + n2).ShouldBe(num);
|
||||
n1.ShouldBeGreaterThanOrEqualTo(20);
|
||||
n2.ShouldBeGreaterThanOrEqualTo(20);
|
||||
}
|
||||
}
|
||||
1694
tests/NATS.Server.Core.Tests/ClientServerGoParityTests.cs
Normal file
1694
tests/NATS.Server.Core.Tests/ClientServerGoParityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
133
tests/NATS.Server.Core.Tests/ClientSlowConsumerTests.cs
Normal file
133
tests/NATS.Server.Core.Tests/ClientSlowConsumerTests.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
// Port of Go client_test.go: TestNoClientLeakOnSlowConsumer, TestClientSlowConsumerWithoutConnect
|
||||
// Reference: golang/nats-server/server/client_test.go lines 2181, 2236
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for slow consumer detection and client cleanup when pending bytes exceed MaxPending.
|
||||
/// Reference: Go TestNoClientLeakOnSlowConsumer (line 2181) and TestClientSlowConsumerWithoutConnect (line 2236)
|
||||
/// </summary>
|
||||
public class ClientSlowConsumerTests
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Slow_consumer_detected_when_pending_exceeds_limit: Creates a server with a small
|
||||
/// MaxPending so that flooding a non-reading subscriber triggers slow consumer detection.
|
||||
/// Verifies that SlowConsumers and SlowConsumerClients stats are incremented, and the
|
||||
/// slow consumer connection is closed cleanly (no leak).
|
||||
///
|
||||
/// Reference: Go TestNoClientLeakOnSlowConsumer (line 2181) and
|
||||
/// TestClientSlowConsumerWithoutConnect (line 2236)
|
||||
///
|
||||
/// The Go tests use write deadline manipulation to force a timeout. Here we use a
|
||||
/// small MaxPending (1KB) so the outbound buffer overflows quickly when flooded
|
||||
/// with 1KB messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Slow_consumer_detected_when_pending_exceeds_limit()
|
||||
{
|
||||
// MaxPending set to 1KB — any subscriber that falls more than 1KB behind
|
||||
// will be classified as a slow consumer and disconnected.
|
||||
const long maxPendingBytes = 1024;
|
||||
const int payloadSize = 512; // each message payload
|
||||
const int floodCount = 50; // enough to exceed the 1KB limit
|
||||
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
Port = port,
|
||||
MaxPending = maxPendingBytes,
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Connect the slow subscriber — it will not read any MSG frames
|
||||
using var slowSub = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await slowSub.ConnectAsync(IPAddress.Loopback, port);
|
||||
|
||||
var buf = new byte[4096];
|
||||
await slowSub.ReceiveAsync(buf, SocketFlags.None); // INFO
|
||||
|
||||
// Subscribe to "flood" subject and confirm with PING/PONG
|
||||
await slowSub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB flood 1\r\nPING\r\n"));
|
||||
var pong = await SocketTestHelper.ReadUntilAsync(slowSub, "PONG");
|
||||
pong.ShouldContain("PONG");
|
||||
|
||||
// Connect the publisher
|
||||
using var pub = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await pub.ConnectAsync(IPAddress.Loopback, port);
|
||||
await pub.ReceiveAsync(buf, SocketFlags.None); // INFO
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\n"));
|
||||
|
||||
// Flood the slow subscriber with messages — it will not drain
|
||||
var payload = new string('X', payloadSize);
|
||||
var pubSb = new StringBuilder();
|
||||
for (int i = 0; i < floodCount; i++)
|
||||
{
|
||||
pubSb.Append($"PUB flood {payloadSize}\r\n{payload}\r\n");
|
||||
}
|
||||
pubSb.Append("PING\r\n");
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(pubSb.ToString()));
|
||||
|
||||
// Wait for publisher's PONG confirming all publishes were processed
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG", timeoutMs: 5000);
|
||||
|
||||
// Give the server time to detect and close the slow consumer
|
||||
await Task.Delay(500);
|
||||
|
||||
// Verify slow consumer stats were incremented
|
||||
var stats = server.Stats;
|
||||
Interlocked.Read(ref stats.SlowConsumers).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref stats.SlowConsumerClients).ShouldBeGreaterThan(0);
|
||||
|
||||
// Verify the slow subscriber was disconnected (connection closed by server).
|
||||
// Drain the slow subscriber socket until 0 bytes (TCP FIN from server).
|
||||
// The server may send a -ERR 'Slow Consumer' before closing, so we read
|
||||
// until the connection is terminated.
|
||||
slowSub.ReceiveTimeout = 3000;
|
||||
int n;
|
||||
bool connectionClosed = false;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
n = slowSub.Receive(buf);
|
||||
if (n == 0)
|
||||
{
|
||||
connectionClosed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
// Socket was forcibly closed — counts as connection closed
|
||||
connectionClosed = true;
|
||||
}
|
||||
connectionClosed.ShouldBeTrue();
|
||||
|
||||
// Verify the slow subscriber is no longer in the server's client list
|
||||
// The server removes the client after detecting the slow consumer condition
|
||||
await Task.Delay(300);
|
||||
server.ClientCount.ShouldBe(1); // only the publisher remains
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
135
tests/NATS.Server.Core.Tests/ClientTests.cs
Normal file
135
tests/NATS.Server.Core.Tests/ClientTests.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System.IO.Pipelines;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ClientTests : IAsyncDisposable
|
||||
{
|
||||
private readonly Socket _serverSocket;
|
||||
private readonly Socket _clientSocket;
|
||||
private readonly NatsClient _natsClient;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public ClientTests()
|
||||
{
|
||||
// Create connected socket pair via loopback
|
||||
var listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
listener.Listen(1);
|
||||
var port = ((IPEndPoint)listener.LocalEndPoint!).Port;
|
||||
|
||||
_clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
_clientSocket.Connect(IPAddress.Loopback, port);
|
||||
_serverSocket = listener.Accept();
|
||||
listener.Dispose();
|
||||
|
||||
var serverInfo = new ServerInfo
|
||||
{
|
||||
ServerId = "test",
|
||||
ServerName = "test",
|
||||
Version = "0.1.0",
|
||||
Host = "127.0.0.1",
|
||||
Port = 4222,
|
||||
};
|
||||
|
||||
var authService = AuthService.Build(new NatsOptions());
|
||||
_natsClient = new NatsClient(1, new NetworkStream(_serverSocket, ownsSocket: false), _serverSocket, new NatsOptions(), serverInfo, authService, null, NullLogger.Instance, new ServerStats());
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
_natsClient.Dispose();
|
||||
_clientSocket.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_sends_INFO_on_start()
|
||||
{
|
||||
var runTask = _natsClient.RunAsync(_cts.Token);
|
||||
|
||||
// Read from client socket -- should get INFO
|
||||
var buf = new byte[4096];
|
||||
var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
|
||||
var response = Encoding.ASCII.GetString(buf, 0, n);
|
||||
|
||||
response.ShouldStartWith("INFO ");
|
||||
response.ShouldContain("server_id");
|
||||
response.ShouldContain("\r\n");
|
||||
|
||||
await _cts.CancelAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_responds_PONG_to_PING()
|
||||
{
|
||||
var runTask = _natsClient.RunAsync(_cts.Token);
|
||||
|
||||
// Read INFO
|
||||
var buf = new byte[4096];
|
||||
await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
|
||||
|
||||
// Send CONNECT then PING
|
||||
await _clientSocket.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
|
||||
|
||||
// Read response -- should get PONG
|
||||
var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
|
||||
var response = Encoding.ASCII.GetString(buf, 0, n);
|
||||
|
||||
response.ShouldContain("PONG\r\n");
|
||||
|
||||
await _cts.CancelAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_SendErrAsync_writes_correct_wire_format()
|
||||
{
|
||||
var runTask = _natsClient.RunAsync(_cts.Token);
|
||||
|
||||
// Read INFO first
|
||||
var buf = new byte[4096];
|
||||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await _clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
|
||||
// Trigger SendErr
|
||||
_natsClient.SendErr("Invalid Subject");
|
||||
|
||||
var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
var response = Encoding.ASCII.GetString(buf, 0, n);
|
||||
|
||||
response.ShouldBe("-ERR 'Invalid Subject'\r\n");
|
||||
|
||||
await _cts.CancelAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_SendErrAndCloseAsync_sends_error_then_disconnects()
|
||||
{
|
||||
var runTask = _natsClient.RunAsync(_cts.Token);
|
||||
|
||||
// Read INFO first
|
||||
var buf = new byte[4096];
|
||||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await _clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
|
||||
// Trigger SendErrAndCloseAsync
|
||||
await _natsClient.SendErrAndCloseAsync("maximum connections exceeded");
|
||||
|
||||
var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
var response = Encoding.ASCII.GetString(buf, 0, n);
|
||||
|
||||
response.ShouldBe("-ERR 'maximum connections exceeded'\r\n");
|
||||
|
||||
// Connection should be closed -- next read returns 0
|
||||
n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
n.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
15
tests/NATS.Server.Core.Tests/ClientTraceModeTests.cs
Normal file
15
tests/NATS.Server.Core.Tests/ClientTraceModeTests.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ClientTraceModeTests
|
||||
{
|
||||
[Fact]
|
||||
public void TraceMode_flag_can_be_set_and_cleared()
|
||||
{
|
||||
var holder = new ClientFlagHolder();
|
||||
holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse();
|
||||
holder.SetFlag(ClientFlags.TraceMode);
|
||||
holder.HasFlag(ClientFlags.TraceMode).ShouldBeTrue();
|
||||
holder.ClearFlag(ClientFlags.TraceMode);
|
||||
holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
136
tests/NATS.Server.Core.Tests/ClientTraceTests.cs
Normal file
136
tests/NATS.Server.Core.Tests/ClientTraceTests.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using NATS.Server;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for per-client trace delivery and echo control.
|
||||
/// Go reference: server/client.go — c.trace, c.echo fields and TraceMsgDelivery / deliverMsg logic.
|
||||
/// </summary>
|
||||
public class ClientTraceTests
|
||||
{
|
||||
// 1. TraceEnabled defaults to false
|
||||
[Fact]
|
||||
public void TraceEnabled_defaults_to_false()
|
||||
{
|
||||
var info = new ClientTraceInfo();
|
||||
info.TraceEnabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// 2. EchoEnabled defaults to true
|
||||
[Fact]
|
||||
public void EchoEnabled_defaults_to_true()
|
||||
{
|
||||
var info = new ClientTraceInfo();
|
||||
info.EchoEnabled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// 3. TraceMsgDelivery records when enabled
|
||||
[Fact]
|
||||
public void TraceMsgDelivery_records_when_enabled()
|
||||
{
|
||||
var info = new ClientTraceInfo();
|
||||
info.TraceEnabled = true;
|
||||
|
||||
info.TraceMsgDelivery("foo.bar", "client-1", 42);
|
||||
|
||||
info.TraceLogCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
// 4. TraceMsgDelivery skips when disabled
|
||||
[Fact]
|
||||
public void TraceMsgDelivery_skips_when_disabled()
|
||||
{
|
||||
var info = new ClientTraceInfo();
|
||||
// TraceEnabled is false by default
|
||||
|
||||
info.TraceMsgDelivery("foo.bar", "client-1", 42);
|
||||
|
||||
info.TraceLogCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// 5. TraceMsgDelivery captures subject, destination and payload size correctly
|
||||
[Fact]
|
||||
public void TraceMsgDelivery_captures_subject_destination_size()
|
||||
{
|
||||
var info = new ClientTraceInfo();
|
||||
info.TraceEnabled = true;
|
||||
var before = DateTime.UtcNow;
|
||||
|
||||
info.TraceMsgDelivery("orders.new", "client-42", 128);
|
||||
|
||||
var records = info.DrainTraceLog();
|
||||
records.Count.ShouldBe(1);
|
||||
var rec = records[0];
|
||||
rec.Subject.ShouldBe("orders.new");
|
||||
rec.Destination.ShouldBe("client-42");
|
||||
rec.PayloadSize.ShouldBe(128);
|
||||
rec.TimestampUtc.ShouldBeGreaterThanOrEqualTo(before);
|
||||
rec.TimestampUtc.ShouldBeLessThanOrEqualTo(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
// 6. ShouldEcho returns true when echo is enabled (same client)
|
||||
[Fact]
|
||||
public void ShouldEcho_true_when_echo_enabled()
|
||||
{
|
||||
var info = new ClientTraceInfo();
|
||||
info.EchoEnabled = true;
|
||||
|
||||
info.ShouldEcho("client-1", "client-1").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// 7. ShouldEcho returns false when echo is disabled and same client
|
||||
[Fact]
|
||||
public void ShouldEcho_false_when_echo_disabled_same_client()
|
||||
{
|
||||
var info = new ClientTraceInfo();
|
||||
info.EchoEnabled = false;
|
||||
|
||||
info.ShouldEcho("client-1", "client-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// 8. ShouldEcho returns true when echo is disabled but different client
|
||||
[Fact]
|
||||
public void ShouldEcho_true_when_echo_disabled_different_client()
|
||||
{
|
||||
var info = new ClientTraceInfo();
|
||||
info.EchoEnabled = false;
|
||||
|
||||
info.ShouldEcho("client-1", "client-2").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// 9. DrainTraceLog returns records and clears the log
|
||||
[Fact]
|
||||
public void DrainTraceLog_returns_and_clears()
|
||||
{
|
||||
var info = new ClientTraceInfo();
|
||||
info.TraceEnabled = true;
|
||||
info.TraceMsgDelivery("a", "dest-a", 10);
|
||||
info.TraceMsgDelivery("b", "dest-b", 20);
|
||||
|
||||
var drained = info.DrainTraceLog();
|
||||
|
||||
drained.Count.ShouldBe(2);
|
||||
info.TraceLogCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// 10. TraceLogCount reflects current entries before and after drain
|
||||
[Fact]
|
||||
public void TraceLogCount_reflects_current_entries()
|
||||
{
|
||||
var info = new ClientTraceInfo();
|
||||
info.TraceEnabled = true;
|
||||
|
||||
info.TraceLogCount.ShouldBe(0);
|
||||
|
||||
info.TraceMsgDelivery("x", "dest-x", 5);
|
||||
info.TraceMsgDelivery("y", "dest-y", 15);
|
||||
info.TraceMsgDelivery("z", "dest-z", 25);
|
||||
|
||||
info.TraceLogCount.ShouldBe(3);
|
||||
|
||||
info.DrainTraceLog();
|
||||
|
||||
info.TraceLogCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
206
tests/NATS.Server.Core.Tests/ClientUnsubTests.cs
Normal file
206
tests/NATS.Server.Core.Tests/ClientUnsubTests.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
// Reference: golang/nats-server/server/client_test.go
|
||||
// Functions: TestClientUnSub, TestClientUnSubMax, TestClientAutoUnsubExactReceived,
|
||||
// TestClientUnsubAfterAutoUnsub, TestClientRemoveSubsOnDisconnect
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ClientUnsubTests : IAsyncLifetime
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public ClientUnsubTests()
|
||||
{
|
||||
_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<Socket> ConnectAndHandshakeAsync()
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, _port);
|
||||
// Drain INFO
|
||||
var buf = new byte[4096];
|
||||
await sock.ReceiveAsync(buf, SocketFlags.None);
|
||||
// Send CONNECT
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n"));
|
||||
return sock;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors TestClientUnSub: subscribe twice, unsubscribe one sid, publish,
|
||||
/// verify only the remaining sid gets the MSG.
|
||||
/// Reference: golang/nats-server/server/client_test.go TestClientUnSub
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Unsub_removes_subscription()
|
||||
{
|
||||
using var pub = await ConnectAndHandshakeAsync();
|
||||
using var sub = await ConnectAndHandshakeAsync();
|
||||
|
||||
// Subscribe to "foo" with sid 1 and sid 2
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nSUB foo 2\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
|
||||
|
||||
// Unsubscribe sid 1
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("UNSUB 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
|
||||
|
||||
// Publish one message to "foo"
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PUB foo 5\r\nHello\r\n"));
|
||||
|
||||
// Should receive exactly one MSG for sid 2; sid 1 is gone
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sub, "MSG foo 2 5");
|
||||
response.ShouldContain("MSG foo 2 5");
|
||||
response.ShouldNotContain("MSG foo 1 5");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors TestClientUnSubMax: UNSUB with a max-messages limit auto-removes
|
||||
/// the subscription after exactly N deliveries.
|
||||
/// Reference: golang/nats-server/server/client_test.go TestClientUnSubMax
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Unsub_max_auto_removes_after_n_messages()
|
||||
{
|
||||
const int maxMessages = 5;
|
||||
const int totalPublishes = 10;
|
||||
|
||||
using var pub = await ConnectAndHandshakeAsync();
|
||||
using var sub = await ConnectAndHandshakeAsync();
|
||||
|
||||
// Subscribe to "foo" with sid 1, limit to 5 messages
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes($"SUB foo 1\r\nUNSUB 1 {maxMessages}\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
|
||||
|
||||
// Publish 10 messages
|
||||
var pubData = new StringBuilder();
|
||||
for (int i = 0; i < totalPublishes; i++)
|
||||
pubData.Append("PUB foo 1\r\nx\r\n");
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(pubData.ToString()));
|
||||
|
||||
// Collect received messages within a short timeout, stopping when no more arrive
|
||||
var received = new StringBuilder();
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(2000);
|
||||
var buf = new byte[4096];
|
||||
while (true)
|
||||
{
|
||||
var n = await sub.ReceiveAsync(buf, SocketFlags.None, timeout.Token);
|
||||
if (n == 0) break;
|
||||
received.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected — timeout means no more messages
|
||||
}
|
||||
|
||||
// Count MSG occurrences
|
||||
var text = received.ToString();
|
||||
var msgCount = CountOccurrences(text, "MSG foo 1");
|
||||
msgCount.ShouldBe(maxMessages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors TestClientUnsubAfterAutoUnsub: after setting a max-messages limit,
|
||||
/// an explicit UNSUB removes the subscription immediately and no messages arrive.
|
||||
/// Reference: golang/nats-server/server/client_test.go TestClientUnsubAfterAutoUnsub
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Unsub_after_auto_unsub_removes_immediately()
|
||||
{
|
||||
using var pub = await ConnectAndHandshakeAsync();
|
||||
using var sub = await ConnectAndHandshakeAsync();
|
||||
|
||||
// Subscribe with a large max-messages limit, then immediately UNSUB without limit
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nUNSUB 1 100\r\nUNSUB 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
|
||||
|
||||
// Publish a message — subscription should already be gone
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PUB foo 5\r\nHello\r\n"));
|
||||
|
||||
// Wait briefly; no MSG should arrive
|
||||
var received = new StringBuilder();
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(500);
|
||||
var buf = new byte[4096];
|
||||
while (true)
|
||||
{
|
||||
var n = await sub.ReceiveAsync(buf, SocketFlags.None, timeout.Token);
|
||||
if (n == 0) break;
|
||||
received.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
|
||||
received.ToString().ShouldNotContain("MSG foo");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors TestClientRemoveSubsOnDisconnect: when a client disconnects the server
|
||||
/// removes all its subscriptions from the global SubList.
|
||||
/// Reference: golang/nats-server/server/client_test.go TestClientRemoveSubsOnDisconnect
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Disconnect_removes_all_subscriptions()
|
||||
{
|
||||
using var client = await ConnectAndHandshakeAsync();
|
||||
|
||||
// Subscribe to 3 distinct subjects
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nSUB bar 2\r\nSUB baz 3\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(client, "PONG");
|
||||
|
||||
// Confirm subscriptions are registered in the server's SubList
|
||||
_server.SubList.Count.ShouldBe(3u);
|
||||
|
||||
// Close the TCP connection abruptly
|
||||
client.Shutdown(SocketShutdown.Both);
|
||||
client.Close();
|
||||
|
||||
// Give the server a moment to detect the disconnect and clean up
|
||||
await Task.Delay(500);
|
||||
|
||||
// All 3 subscriptions should be removed
|
||||
_server.SubList.Count.ShouldBe(0u);
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string haystack, string needle)
|
||||
{
|
||||
int count = 0;
|
||||
int index = 0;
|
||||
while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
index += needle.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
0
tests/NATS.Server.Core.Tests/Concurrency/.gitkeep
Normal file
0
tests/NATS.Server.Core.Tests/Concurrency/.gitkeep
Normal file
1286
tests/NATS.Server.Core.Tests/ConcurrencyStressTests.cs
Normal file
1286
tests/NATS.Server.Core.Tests/ConcurrencyStressTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
76
tests/NATS.Server.Core.Tests/ConfigIntegrationTests.cs
Normal file
76
tests/NATS.Server.Core.Tests/ConfigIntegrationTests.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ConfigIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Server_WithConfigFile_LoadsOptionsFromFile()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var confPath = Path.Combine(dir, "test.conf");
|
||||
File.WriteAllText(confPath, "port: 14222\nmax_payload: 2mb\ndebug: true");
|
||||
|
||||
var opts = ConfigProcessor.ProcessConfigFile(confPath);
|
||||
opts.Port.ShouldBe(14222);
|
||||
opts.MaxPayload.ShouldBe(2 * 1024 * 1024);
|
||||
opts.Debug.ShouldBeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_CliOverridesConfig()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var confPath = Path.Combine(dir, "test.conf");
|
||||
File.WriteAllText(confPath, "port: 14222\ndebug: true");
|
||||
|
||||
var opts = ConfigProcessor.ProcessConfigFile(confPath);
|
||||
opts.Port.ShouldBe(14222);
|
||||
|
||||
// Simulate CLI override: user passed -p 5222 on command line
|
||||
var cliSnapshot = new NatsOptions { Port = 5222 };
|
||||
var cliFlags = new HashSet<string> { "Port" };
|
||||
ConfigReloader.MergeCliOverrides(opts, cliSnapshot, cliFlags);
|
||||
|
||||
opts.Port.ShouldBe(5222);
|
||||
opts.Debug.ShouldBeTrue(); // Config file value preserved
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reload_ChangingPort_ReturnsError()
|
||||
{
|
||||
var oldOpts = new NatsOptions { Port = 4222 };
|
||||
var newOpts = new NatsOptions { Port = 5222 };
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var errors = ConfigReloader.Validate(changes);
|
||||
errors.Count.ShouldBeGreaterThan(0);
|
||||
errors[0].ShouldContain("Port");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reload_ChangingDebug_IsValid()
|
||||
{
|
||||
var oldOpts = new NatsOptions { Debug = false };
|
||||
var newOpts = new NatsOptions { Debug = true };
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var errors = ConfigReloader.Validate(changes);
|
||||
errors.ShouldBeEmpty();
|
||||
changes.ShouldContain(c => c.IsLoggingChange);
|
||||
}
|
||||
}
|
||||
612
tests/NATS.Server.Core.Tests/ConfigProcessorTests.cs
Normal file
612
tests/NATS.Server.Core.Tests/ConfigProcessorTests.cs
Normal file
@@ -0,0 +1,612 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ConfigProcessorTests
|
||||
{
|
||||
private static string TestDataPath(string fileName) =>
|
||||
Path.Combine(AppContext.BaseDirectory, "TestData", fileName);
|
||||
|
||||
// ─── Basic config ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_Port()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.Port.ShouldBe(4222);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_Host()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.Host.ShouldBe("0.0.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_ServerName()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.ServerName.ShouldBe("test-server");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_MaxPayload()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.MaxPayload.ShouldBe(2 * 1024 * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_MaxConnections()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.MaxConnections.ShouldBe(1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_Debug()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.Debug.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_Trace()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.Trace.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_PingInterval()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_MaxPingsOut()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.MaxPingsOut.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_WriteDeadline()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_MaxSubs()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.MaxSubs.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_MaxSubTokens()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.MaxSubTokens.ShouldBe(16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_MaxControlLine()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.MaxControlLine.ShouldBe(2048);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_MaxPending()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.MaxPending.ShouldBe(32L * 1024 * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_LameDuckDuration()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.LameDuckDuration.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_LameDuckGracePeriod()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.LameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_MonitorPort()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.MonitorPort.ShouldBe(8222);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BasicConf_Logtime()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
|
||||
opts.Logtime.ShouldBeTrue();
|
||||
opts.LogtimeUTC.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ─── Auth config ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AuthConf_SimpleUser()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
|
||||
opts.Username.ShouldBe("admin");
|
||||
opts.Password.ShouldBe("s3cret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthConf_AuthTimeout()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
|
||||
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthConf_NoAuthUser()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
|
||||
opts.NoAuthUser.ShouldBe("guest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthConf_UsersArray()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
|
||||
opts.Users.ShouldNotBeNull();
|
||||
opts.Users.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthConf_AliceUser()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
|
||||
var alice = opts.Users!.First(u => u.Username == "alice");
|
||||
alice.Password.ShouldBe("pw1");
|
||||
alice.Permissions.ShouldNotBeNull();
|
||||
alice.Permissions!.Publish.ShouldNotBeNull();
|
||||
alice.Permissions.Publish!.Allow.ShouldNotBeNull();
|
||||
alice.Permissions.Publish.Allow!.ShouldContain("foo.>");
|
||||
alice.Permissions.Subscribe.ShouldNotBeNull();
|
||||
alice.Permissions.Subscribe!.Allow.ShouldNotBeNull();
|
||||
alice.Permissions.Subscribe.Allow!.ShouldContain(">");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthConf_BobUser()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
|
||||
var bob = opts.Users!.First(u => u.Username == "bob");
|
||||
bob.Password.ShouldBe("pw2");
|
||||
bob.Permissions.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ─── TLS config ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TlsConf_CertFiles()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
|
||||
opts.TlsCert.ShouldBe("/path/to/cert.pem");
|
||||
opts.TlsKey.ShouldBe("/path/to/key.pem");
|
||||
opts.TlsCaCert.ShouldBe("/path/to/ca.pem");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TlsConf_Verify()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
|
||||
opts.TlsVerify.ShouldBeTrue();
|
||||
opts.TlsMap.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TlsConf_Timeout()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
|
||||
opts.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TlsConf_RateLimit()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
|
||||
opts.TlsRateLimit.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TlsConf_PinnedCerts()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
|
||||
opts.TlsPinnedCerts.ShouldNotBeNull();
|
||||
opts.TlsPinnedCerts!.Count.ShouldBe(1);
|
||||
opts.TlsPinnedCerts.ShouldContain("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TlsConf_HandshakeFirst()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
|
||||
opts.TlsHandshakeFirst.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TlsConf_AllowNonTls()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
|
||||
opts.AllowNonTls.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ─── Full config ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FullConf_CoreOptions()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
|
||||
opts.Port.ShouldBe(4222);
|
||||
opts.Host.ShouldBe("0.0.0.0");
|
||||
opts.ServerName.ShouldBe("full-test");
|
||||
opts.ClientAdvertise.ShouldBe("nats://public.example.com:4222");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullConf_Limits()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
|
||||
opts.MaxPayload.ShouldBe(1024 * 1024);
|
||||
opts.MaxControlLine.ShouldBe(4096);
|
||||
opts.MaxConnections.ShouldBe(65536);
|
||||
opts.MaxPending.ShouldBe(64L * 1024 * 1024);
|
||||
opts.MaxSubs.ShouldBe(0);
|
||||
opts.MaxSubTokens.ShouldBe(0);
|
||||
opts.MaxTracedMsgLen.ShouldBe(1024);
|
||||
opts.DisableSublistCache.ShouldBeFalse();
|
||||
opts.MaxClosedClients.ShouldBe(5000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullConf_Logging()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
|
||||
opts.Debug.ShouldBeFalse();
|
||||
opts.Trace.ShouldBeFalse();
|
||||
opts.TraceVerbose.ShouldBeFalse();
|
||||
opts.Logtime.ShouldBeTrue();
|
||||
opts.LogtimeUTC.ShouldBeFalse();
|
||||
opts.LogFile.ShouldBe("/var/log/nats.log");
|
||||
opts.LogSizeLimit.ShouldBe(100L * 1024 * 1024);
|
||||
opts.LogMaxFiles.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullConf_Monitoring()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
|
||||
opts.MonitorPort.ShouldBe(8222);
|
||||
opts.MonitorBasePath.ShouldBe("/nats");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullConf_Files()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
|
||||
opts.PidFile.ShouldBe("/var/run/nats.pid");
|
||||
opts.PortsFileDir.ShouldBe("/var/run");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullConf_Lifecycle()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
|
||||
opts.LameDuckDuration.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
opts.LameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullConf_Tags()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
|
||||
opts.Tags.ShouldNotBeNull();
|
||||
opts.Tags!["region"].ShouldBe("us-east");
|
||||
opts.Tags["env"].ShouldBe("production");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullConf_Auth()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
|
||||
opts.Username.ShouldBe("admin");
|
||||
opts.Password.ShouldBe("secret");
|
||||
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullConf_Tls()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
|
||||
opts.TlsCert.ShouldBe("/path/to/cert.pem");
|
||||
opts.TlsKey.ShouldBe("/path/to/key.pem");
|
||||
opts.TlsCaCert.ShouldBe("/path/to/ca.pem");
|
||||
opts.TlsVerify.ShouldBeTrue();
|
||||
opts.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
opts.TlsHandshakeFirst.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ─── Listen combined format ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ListenCombined_HostAndPort()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("listen: \"10.0.0.1:5222\"");
|
||||
opts.Host.ShouldBe("10.0.0.1");
|
||||
opts.Port.ShouldBe(5222);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListenCombined_PortOnly()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("listen: \":5222\"");
|
||||
opts.Port.ShouldBe(5222);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListenCombined_BarePort()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("listen: 5222");
|
||||
opts.Port.ShouldBe(5222);
|
||||
}
|
||||
|
||||
// ─── HTTP combined format ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HttpCombined_HostAndPort()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("http: \"10.0.0.1:8333\"");
|
||||
opts.MonitorHost.ShouldBe("10.0.0.1");
|
||||
opts.MonitorPort.ShouldBe(8333);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HttpsCombined_HostAndPort()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("https: \"10.0.0.1:8444\"");
|
||||
opts.MonitorHost.ShouldBe("10.0.0.1");
|
||||
opts.MonitorHttpsPort.ShouldBe(8444);
|
||||
}
|
||||
|
||||
// ─── Duration as number ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void DurationAsNumber_TreatedAsSeconds()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("ping_interval: 60");
|
||||
opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DurationAsString_Milliseconds()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("write_deadline: \"500ms\"");
|
||||
opts.WriteDeadline.ShouldBe(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DurationAsString_Hours()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("ping_interval: \"1h\"");
|
||||
opts.PingInterval.ShouldBe(TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
// ─── Unknown keys ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UnknownKeys_SilentlyIgnored()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("""
|
||||
port: 4222
|
||||
cluster { name: "my-cluster" }
|
||||
jetstream { store_dir: "/tmp/js" }
|
||||
unknown_key: "whatever"
|
||||
""");
|
||||
opts.Port.ShouldBe(4222);
|
||||
}
|
||||
|
||||
// ─── Server name validation ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ServerNameWithSpaces_ReportsError()
|
||||
{
|
||||
var ex = Should.Throw<ConfigProcessorException>(() =>
|
||||
ConfigProcessor.ProcessConfig("server_name: \"my server\""));
|
||||
ex.Errors.ShouldContain(e => e.Contains("server_name cannot contain spaces"));
|
||||
}
|
||||
|
||||
// ─── Max sub tokens validation ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MaxSubTokens_ExceedsLimit_ReportsError()
|
||||
{
|
||||
var ex = Should.Throw<ConfigProcessorException>(() =>
|
||||
ConfigProcessor.ProcessConfig("max_sub_tokens: 300"));
|
||||
ex.Errors.ShouldContain(e => e.Contains("max_sub_tokens cannot exceed 256"));
|
||||
}
|
||||
|
||||
// ─── ProcessConfig from string ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ProcessConfig_FromString()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("""
|
||||
port: 9222
|
||||
host: "127.0.0.1"
|
||||
debug: true
|
||||
""");
|
||||
opts.Port.ShouldBe(9222);
|
||||
opts.Host.ShouldBe("127.0.0.1");
|
||||
opts.Debug.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ─── TraceVerbose sets Trace ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TraceVerbose_AlsoSetsTrace()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("trace_verbose: true");
|
||||
opts.TraceVerbose.ShouldBeTrue();
|
||||
opts.Trace.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ─── Error collection (not fail-fast) ──────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MultipleErrors_AllCollected()
|
||||
{
|
||||
var ex = Should.Throw<ConfigProcessorException>(() =>
|
||||
ConfigProcessor.ProcessConfig("""
|
||||
server_name: "bad name"
|
||||
max_sub_tokens: 999
|
||||
"""));
|
||||
ex.Errors.Count.ShouldBe(2);
|
||||
ex.Errors.ShouldContain(e => e.Contains("server_name"));
|
||||
ex.Errors.ShouldContain(e => e.Contains("max_sub_tokens"));
|
||||
}
|
||||
|
||||
// ─── ConfigFile path tracking ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ProcessConfigFile_SetsConfigFilePath()
|
||||
{
|
||||
var path = TestDataPath("basic.conf");
|
||||
var opts = ConfigProcessor.ProcessConfigFile(path);
|
||||
opts.ConfigFile.ShouldBe(path);
|
||||
}
|
||||
|
||||
// ─── HasTls derived property ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HasTls_TrueWhenCertAndKeySet()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
|
||||
opts.HasTls.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ─── MQTT config ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_ListenHostAndPort()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("mqtt.conf"));
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.Host.ShouldBe("10.0.0.1");
|
||||
opts.Mqtt.Port.ShouldBe(1883);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_NoAuthUser()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("mqtt.conf"));
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.NoAuthUser.ShouldBe("mqtt_default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_Authorization()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("mqtt.conf"));
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.Username.ShouldBe("mqtt_user");
|
||||
opts.Mqtt.Password.ShouldBe("mqtt_pass");
|
||||
opts.Mqtt.Token.ShouldBe("mqtt_token");
|
||||
opts.Mqtt.AuthTimeout.ShouldBe(3.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_Tls()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("mqtt.conf"));
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.TlsCert.ShouldBe("/path/to/mqtt-cert.pem");
|
||||
opts.Mqtt.TlsKey.ShouldBe("/path/to/mqtt-key.pem");
|
||||
opts.Mqtt.TlsCaCert.ShouldBe("/path/to/mqtt-ca.pem");
|
||||
opts.Mqtt.TlsVerify.ShouldBeTrue();
|
||||
opts.Mqtt.TlsTimeout.ShouldBe(5.0);
|
||||
opts.Mqtt.HasTls.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_QosSettings()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("mqtt.conf"));
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.AckWait.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
opts.Mqtt.MaxAckPending.ShouldBe((ushort)2048);
|
||||
opts.Mqtt.JsApiTimeout.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_JetStreamSettings()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("mqtt.conf"));
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.JsDomain.ShouldBe("mqtt-domain");
|
||||
opts.Mqtt.StreamReplicas.ShouldBe(3);
|
||||
opts.Mqtt.ConsumerReplicas.ShouldBe(1);
|
||||
opts.Mqtt.ConsumerMemoryStorage.ShouldBeTrue();
|
||||
opts.Mqtt.ConsumerInactiveThreshold.ShouldBe(TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_MaxAckPendingValidation_ReportsError()
|
||||
{
|
||||
var ex = Should.Throw<ConfigProcessorException>(() =>
|
||||
ConfigProcessor.ProcessConfig("""
|
||||
mqtt {
|
||||
max_ack_pending: 70000
|
||||
}
|
||||
"""));
|
||||
ex.Errors.ShouldContain(e => e.Contains("max_ack_pending"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_Aliases()
|
||||
{
|
||||
// Test alias keys: "ackwait" (alias for "ack_wait"), "net" (alias for "host"),
|
||||
// "max_inflight" (alias for "max_ack_pending"), "consumer_auto_cleanup" (alias)
|
||||
var opts = ConfigProcessor.ProcessConfig("""
|
||||
mqtt {
|
||||
net: "127.0.0.1"
|
||||
port: 1884
|
||||
ackwait: "45s"
|
||||
max_inflight: 500
|
||||
api_timeout: "8s"
|
||||
consumer_auto_cleanup: "10m"
|
||||
}
|
||||
""");
|
||||
opts.Mqtt.ShouldNotBeNull();
|
||||
opts.Mqtt!.Host.ShouldBe("127.0.0.1");
|
||||
opts.Mqtt.Port.ShouldBe(1884);
|
||||
opts.Mqtt.AckWait.ShouldBe(TimeSpan.FromSeconds(45));
|
||||
opts.Mqtt.MaxAckPending.ShouldBe((ushort)500);
|
||||
opts.Mqtt.JsApiTimeout.ShouldBe(TimeSpan.FromSeconds(8));
|
||||
opts.Mqtt.ConsumerInactiveThreshold.ShouldBe(TimeSpan.FromMinutes(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MqttConf_Absent_ReturnsNull()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("port: 4222");
|
||||
opts.Mqtt.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
89
tests/NATS.Server.Core.Tests/ConfigReloadTests.cs
Normal file
89
tests/NATS.Server.Core.Tests/ConfigReloadTests.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ConfigReloadTests
|
||||
{
|
||||
[Fact]
|
||||
public void Diff_NoChanges_ReturnsEmpty()
|
||||
{
|
||||
var old = new NatsOptions { Port = 4222, Debug = true };
|
||||
var @new = new NatsOptions { Port = 4222, Debug = true };
|
||||
var changes = ConfigReloader.Diff(old, @new);
|
||||
changes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diff_ReloadableChange_ReturnsChange()
|
||||
{
|
||||
var old = new NatsOptions { Debug = false };
|
||||
var @new = new NatsOptions { Debug = true };
|
||||
var changes = ConfigReloader.Diff(old, @new);
|
||||
changes.Count.ShouldBe(1);
|
||||
changes[0].Name.ShouldBe("Debug");
|
||||
changes[0].IsLoggingChange.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diff_NonReloadableChange_ReturnsNonReloadableChange()
|
||||
{
|
||||
var old = new NatsOptions { Port = 4222 };
|
||||
var @new = new NatsOptions { Port = 5222 };
|
||||
var changes = ConfigReloader.Diff(old, @new);
|
||||
changes.Count.ShouldBe(1);
|
||||
changes[0].IsNonReloadable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diff_MultipleChanges_ReturnsAll()
|
||||
{
|
||||
var old = new NatsOptions { Debug = false, MaxPayload = 1024 };
|
||||
var @new = new NatsOptions { Debug = true, MaxPayload = 2048 };
|
||||
var changes = ConfigReloader.Diff(old, @new);
|
||||
changes.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diff_AuthChange_MarkedCorrectly()
|
||||
{
|
||||
var old = new NatsOptions { Username = "alice" };
|
||||
var @new = new NatsOptions { Username = "bob" };
|
||||
var changes = ConfigReloader.Diff(old, @new);
|
||||
changes[0].IsAuthChange.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diff_TlsChange_MarkedCorrectly()
|
||||
{
|
||||
var old = new NatsOptions { TlsCert = "/old/cert.pem" };
|
||||
var @new = new NatsOptions { TlsCert = "/new/cert.pem" };
|
||||
var changes = ConfigReloader.Diff(old, @new);
|
||||
changes[0].IsTlsChange.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NonReloadableChanges_ReturnsErrors()
|
||||
{
|
||||
var changes = new List<IConfigChange>
|
||||
{
|
||||
new ConfigChange("Port", isNonReloadable: true),
|
||||
};
|
||||
var errors = ConfigReloader.Validate(changes);
|
||||
errors.Count.ShouldBe(1);
|
||||
errors[0].ShouldContain("Port");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeWithCli_CliOverridesConfig()
|
||||
{
|
||||
var fromConfig = new NatsOptions { Port = 5222, Debug = true };
|
||||
var cliFlags = new HashSet<string> { "Port" };
|
||||
var cliValues = new NatsOptions { Port = 4222 };
|
||||
|
||||
ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags);
|
||||
fromConfig.Port.ShouldBe(4222); // CLI wins
|
||||
fromConfig.Debug.ShouldBeTrue(); // config value kept (not in CLI)
|
||||
}
|
||||
}
|
||||
36
tests/NATS.Server.Core.Tests/ConfigRuntimeParityTests.cs
Normal file
36
tests/NATS.Server.Core.Tests/ConfigRuntimeParityTests.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ConfigRuntimeParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Profiling_endpoint_returns_runtime_profile_artifacts_and_config_options_map_to_runtime_behavior()
|
||||
{
|
||||
_ = await Task.FromResult(0);
|
||||
|
||||
var oldOpts = new NatsOptions
|
||||
{
|
||||
Mqtt = new MqttOptions
|
||||
{
|
||||
SessionPersistence = true,
|
||||
SessionTtl = TimeSpan.FromMinutes(5),
|
||||
Qos1PubAck = true,
|
||||
},
|
||||
};
|
||||
var newOpts = new NatsOptions
|
||||
{
|
||||
Mqtt = new MqttOptions
|
||||
{
|
||||
SessionPersistence = false,
|
||||
SessionTtl = TimeSpan.FromMinutes(1),
|
||||
Qos1PubAck = false,
|
||||
},
|
||||
};
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
changes.Select(c => c.Name).ShouldContain("Mqtt.SessionPersistence");
|
||||
changes.Select(c => c.Name).ShouldContain("Mqtt.SessionTtl");
|
||||
changes.Select(c => c.Name).ShouldContain("Mqtt.Qos1PubAck");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Port of Go server/reload.go — auth change propagation tests.
|
||||
// Reference: golang/nats-server/server/reload.go — authOption.Apply, usersOption.Apply.
|
||||
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Configuration;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Configuration;
|
||||
|
||||
public sealed class AuthChangePropagationTests
|
||||
{
|
||||
// ─── helpers ────────────────────────────────────────────────────
|
||||
|
||||
private static User MakeUser(string username, string password = "pw") =>
|
||||
new() { Username = username, Password = password };
|
||||
|
||||
private static NatsOptions BaseOpts() => new();
|
||||
|
||||
// ─── tests ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void No_changes_returns_no_changes()
|
||||
{
|
||||
// Same empty options → nothing changed.
|
||||
var oldOpts = BaseOpts();
|
||||
var newOpts = BaseOpts();
|
||||
|
||||
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
|
||||
|
||||
result.HasChanges.ShouldBeFalse();
|
||||
result.UsersChanged.ShouldBeFalse();
|
||||
result.AccountsChanged.ShouldBeFalse();
|
||||
result.TokenChanged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_added_detected()
|
||||
{
|
||||
// Adding a user must set UsersChanged.
|
||||
var oldOpts = BaseOpts();
|
||||
var newOpts = BaseOpts();
|
||||
newOpts.Users = [MakeUser("alice")];
|
||||
|
||||
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
|
||||
|
||||
result.UsersChanged.ShouldBeTrue();
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_removed_detected()
|
||||
{
|
||||
// Removing a user must set UsersChanged.
|
||||
var oldOpts = BaseOpts();
|
||||
oldOpts.Users = [MakeUser("alice")];
|
||||
var newOpts = BaseOpts();
|
||||
|
||||
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
|
||||
|
||||
result.UsersChanged.ShouldBeTrue();
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_added_detected()
|
||||
{
|
||||
// Adding an account must set AccountsChanged.
|
||||
var oldOpts = BaseOpts();
|
||||
var newOpts = BaseOpts();
|
||||
newOpts.Accounts = new Dictionary<string, AccountConfig>
|
||||
{
|
||||
["engineering"] = new AccountConfig()
|
||||
};
|
||||
|
||||
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
|
||||
|
||||
result.AccountsChanged.ShouldBeTrue();
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_changed_detected()
|
||||
{
|
||||
// Changing the Authorization token must set TokenChanged.
|
||||
var oldOpts = BaseOpts();
|
||||
oldOpts.Authorization = "old-secret-token";
|
||||
var newOpts = BaseOpts();
|
||||
newOpts.Authorization = "new-secret-token";
|
||||
|
||||
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
|
||||
|
||||
result.TokenChanged.ShouldBeTrue();
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_changes_all_flagged()
|
||||
{
|
||||
// Changing both users and accounts must set both flags.
|
||||
var oldOpts = BaseOpts();
|
||||
oldOpts.Users = [MakeUser("alice")];
|
||||
oldOpts.Accounts = new Dictionary<string, AccountConfig>
|
||||
{
|
||||
["acct-a"] = new AccountConfig()
|
||||
};
|
||||
|
||||
var newOpts = BaseOpts();
|
||||
newOpts.Users = [MakeUser("alice"), MakeUser("bob")];
|
||||
newOpts.Accounts = new Dictionary<string, AccountConfig>
|
||||
{
|
||||
["acct-a"] = new AccountConfig(),
|
||||
["acct-b"] = new AccountConfig()
|
||||
};
|
||||
|
||||
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
|
||||
|
||||
result.UsersChanged.ShouldBeTrue();
|
||||
result.AccountsChanged.ShouldBeTrue();
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_users_different_order_no_change()
|
||||
{
|
||||
// Users in a different order with the same names must NOT trigger UsersChanged
|
||||
// because the comparison is set-based.
|
||||
var oldOpts = BaseOpts();
|
||||
oldOpts.Users = [MakeUser("alice"), MakeUser("bob")];
|
||||
|
||||
var newOpts = BaseOpts();
|
||||
newOpts.Users = [MakeUser("bob"), MakeUser("alice")];
|
||||
|
||||
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
|
||||
|
||||
result.UsersChanged.ShouldBeFalse();
|
||||
result.HasChanges.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasChanges_true_when_any_change()
|
||||
{
|
||||
// A single changed field (token only) is enough to set HasChanges.
|
||||
var oldOpts = BaseOpts();
|
||||
var newOpts = BaseOpts();
|
||||
newOpts.Authorization = "token-xyz";
|
||||
|
||||
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
|
||||
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_to_non_empty_users_detected()
|
||||
{
|
||||
// Going from zero users to one user must be detected.
|
||||
var oldOpts = BaseOpts();
|
||||
// No Users assigned — null list.
|
||||
var newOpts = BaseOpts();
|
||||
newOpts.Users = [MakeUser("charlie")];
|
||||
|
||||
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
|
||||
|
||||
result.UsersChanged.ShouldBeTrue();
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_auth_to_auth_detected()
|
||||
{
|
||||
// Going from null Authorization to a token string must be detected.
|
||||
var oldOpts = BaseOpts();
|
||||
// Authorization is null by default.
|
||||
var newOpts = BaseOpts();
|
||||
newOpts.Authorization = "brand-new-token";
|
||||
|
||||
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
|
||||
|
||||
result.TokenChanged.ShouldBeTrue();
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_token_no_change()
|
||||
{
|
||||
// The same token value on both sides must NOT flag TokenChanged.
|
||||
var oldOpts = BaseOpts();
|
||||
oldOpts.Authorization = "stable-token";
|
||||
var newOpts = BaseOpts();
|
||||
newOpts.Authorization = "stable-token";
|
||||
|
||||
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
|
||||
|
||||
result.TokenChanged.ShouldBeFalse();
|
||||
result.HasChanges.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
391
tests/NATS.Server.Core.Tests/Configuration/AuthReloadTests.cs
Normal file
391
tests/NATS.Server.Core.Tests/Configuration/AuthReloadTests.cs
Normal file
@@ -0,0 +1,391 @@
|
||||
// Port of Go server/reload_test.go — TestConfigReloadAuthChangeDisconnects,
|
||||
// TestConfigReloadAuthEnabled, TestConfigReloadAuthDisabled,
|
||||
// TestConfigReloadUserCredentialChange.
|
||||
// Reference: golang/nats-server/server/reload_test.go lines 720-900.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for auth change propagation on config reload.
|
||||
/// Covers:
|
||||
/// - Enabling auth disconnects unauthenticated clients
|
||||
/// - Changing credentials disconnects clients with old credentials
|
||||
/// - Disabling auth allows previously rejected connections
|
||||
/// - Clients with correct credentials survive reload
|
||||
/// Reference: Go server/reload.go — reloadAuthorization.
|
||||
/// </summary>
|
||||
public class AuthReloadTests
|
||||
{
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<Socket> RawConnectAsync(int port)
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||||
var buf = new byte[4096];
|
||||
await sock.ReceiveAsync(buf, SocketFlags.None);
|
||||
return sock;
|
||||
}
|
||||
|
||||
private static async Task SendConnectAsync(Socket sock, string? user = null, string? pass = null)
|
||||
{
|
||||
string connectJson;
|
||||
if (user != null && pass != null)
|
||||
connectJson = $"CONNECT {{\"verbose\":false,\"pedantic\":false,\"user\":\"{user}\",\"pass\":\"{pass}\"}}\r\n";
|
||||
else
|
||||
connectJson = "CONNECT {\"verbose\":false,\"pedantic\":false}\r\n";
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes(connectJson), SocketFlags.None);
|
||||
}
|
||||
|
||||
private static void WriteConfigAndReload(NatsServer server, string configPath, string configText)
|
||||
{
|
||||
File.WriteAllText(configPath, configText);
|
||||
server.ReloadConfigOrThrow();
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Port of Go TestConfigReloadAuthChangeDisconnects (reload_test.go).
|
||||
///
|
||||
/// Verifies that enabling authentication via hot reload disconnects clients
|
||||
/// that connected without credentials. The server should send -ERR
|
||||
/// 'Authorization Violation' and close the connection.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Enabling_auth_disconnects_unauthenticated_clients()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-authdc-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
|
||||
// Start with no auth
|
||||
File.WriteAllText(configPath, $"port: {port}\ndebug: false");
|
||||
|
||||
var options = new NatsOptions { ConfigFile = configPath, Port = port };
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Connect a client without credentials
|
||||
using var sock = await RawConnectAsync(port);
|
||||
await SendConnectAsync(sock);
|
||||
|
||||
// Send a PING to confirm the connection is established
|
||||
await sock.SendAsync("PING\r\n"u8.ToArray(), SocketFlags.None);
|
||||
var pong = await SocketTestHelper.ReadUntilAsync(sock, "PONG", timeoutMs: 3000);
|
||||
pong.ShouldContain("PONG");
|
||||
|
||||
server.ClientCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
|
||||
// Enable auth via reload
|
||||
WriteConfigAndReload(server, configPath,
|
||||
$"port: {port}\nauthorization {{\n user: admin\n password: secret123\n}}");
|
||||
|
||||
// The unauthenticated client should receive an -ERR and/or be disconnected.
|
||||
// Read whatever the server sends before closing the socket.
|
||||
var errResponse = await ReadAllBeforeCloseAsync(sock, timeoutMs: 5000);
|
||||
// The server should have sent -ERR 'Authorization Violation' before closing
|
||||
errResponse.ShouldContain("Authorization Violation",
|
||||
Case.Insensitive,
|
||||
$"Expected 'Authorization Violation' in response but got: '{errResponse}'");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that changing user credentials disconnects clients using old credentials.
|
||||
/// Reference: Go server/reload_test.go — TestConfigReloadUserCredentialChange.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Changing_credentials_disconnects_old_credential_clients()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-credchg-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
|
||||
// Start with user/password auth
|
||||
File.WriteAllText(configPath,
|
||||
$"port: {port}\nauthorization {{\n user: alice\n password: pass1\n}}");
|
||||
|
||||
var options = ConfigProcessor.ProcessConfigFile(configPath);
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Connect with the original credentials
|
||||
using var sock = await RawConnectAsync(port);
|
||||
await SendConnectAsync(sock, "alice", "pass1");
|
||||
|
||||
// Verify connection works
|
||||
await sock.SendAsync("PING\r\n"u8.ToArray(), SocketFlags.None);
|
||||
var pong = await SocketTestHelper.ReadUntilAsync(sock, "PONG", timeoutMs: 3000);
|
||||
pong.ShouldContain("PONG");
|
||||
|
||||
// Change the password via reload
|
||||
WriteConfigAndReload(server, configPath,
|
||||
$"port: {port}\nauthorization {{\n user: alice\n password: pass2\n}}");
|
||||
|
||||
// The client with the old password should be disconnected
|
||||
var errResponse = await ReadAllBeforeCloseAsync(sock, timeoutMs: 5000);
|
||||
errResponse.ShouldContain("Authorization Violation",
|
||||
Case.Insensitive,
|
||||
$"Expected 'Authorization Violation' in response but got: '{errResponse}'");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that disabling auth on reload allows new unauthenticated connections.
|
||||
/// Reference: Go server/reload_test.go — TestConfigReloadDisableUserAuthentication.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Disabling_auth_allows_new_connections()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-authoff-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
|
||||
// Start with auth enabled
|
||||
File.WriteAllText(configPath,
|
||||
$"port: {port}\nauthorization {{\n user: bob\n password: secret\n}}");
|
||||
|
||||
var options = ConfigProcessor.ProcessConfigFile(configPath);
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Verify unauthenticated connections are rejected
|
||||
await using var noAuthClient = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{port}",
|
||||
MaxReconnectRetry = 0,
|
||||
});
|
||||
|
||||
var ex = await Should.ThrowAsync<NatsException>(async () =>
|
||||
{
|
||||
await noAuthClient.ConnectAsync();
|
||||
await noAuthClient.PingAsync();
|
||||
});
|
||||
ContainsInChain(ex, "Authorization Violation").ShouldBeTrue();
|
||||
|
||||
// Disable auth via reload
|
||||
WriteConfigAndReload(server, configPath, $"port: {port}\ndebug: false");
|
||||
|
||||
// New connections without credentials should now succeed
|
||||
await using var newClient = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{port}",
|
||||
});
|
||||
await newClient.ConnectAsync();
|
||||
await newClient.PingAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that clients with the new correct credentials survive an auth reload.
|
||||
/// This connects a new client after the reload with the new credentials and
|
||||
/// verifies it works.
|
||||
/// Reference: Go server/reload_test.go — TestConfigReloadEnableUserAuthentication.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task New_clients_with_correct_credentials_work_after_auth_reload()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-newauth-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
|
||||
// Start with no auth
|
||||
File.WriteAllText(configPath, $"port: {port}\ndebug: false");
|
||||
|
||||
var options = new NatsOptions { ConfigFile = configPath, Port = port };
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Enable auth via reload
|
||||
WriteConfigAndReload(server, configPath,
|
||||
$"port: {port}\nauthorization {{\n user: carol\n password: newpass\n}}");
|
||||
|
||||
// New connection with correct credentials should succeed
|
||||
await using var authClient = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://carol:newpass@127.0.0.1:{port}",
|
||||
});
|
||||
await authClient.ConnectAsync();
|
||||
await authClient.PingAsync();
|
||||
|
||||
// New connection without credentials should be rejected
|
||||
await using var noAuthClient = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{port}",
|
||||
MaxReconnectRetry = 0,
|
||||
});
|
||||
|
||||
var ex = await Should.ThrowAsync<NatsException>(async () =>
|
||||
{
|
||||
await noAuthClient.ConnectAsync();
|
||||
await noAuthClient.PingAsync();
|
||||
});
|
||||
ContainsInChain(ex, "Authorization Violation").ShouldBeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that PropagateAuthChanges is a no-op when auth is disabled.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PropagateAuthChanges_noop_when_auth_disabled()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-noauth-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
File.WriteAllText(configPath, $"port: {port}\ndebug: false");
|
||||
|
||||
var options = new NatsOptions { ConfigFile = configPath, Port = port };
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Connect a client
|
||||
using var sock = await RawConnectAsync(port);
|
||||
await SendConnectAsync(sock);
|
||||
await sock.SendAsync("PING\r\n"u8.ToArray(), SocketFlags.None);
|
||||
var pong = await SocketTestHelper.ReadUntilAsync(sock, "PONG", timeoutMs: 3000);
|
||||
pong.ShouldContain("PONG");
|
||||
|
||||
var countBefore = server.ClientCount;
|
||||
|
||||
// Reload with a logging change only (no auth change)
|
||||
WriteConfigAndReload(server, configPath, $"port: {port}\ndebug: true");
|
||||
|
||||
// Wait a moment for any async operations
|
||||
await Task.Delay(200);
|
||||
|
||||
// Client count should remain the same (no disconnections)
|
||||
server.ClientCount.ShouldBe(countBefore);
|
||||
|
||||
// Client should still be responsive
|
||||
await sock.SendAsync("PING\r\n"u8.ToArray(), SocketFlags.None);
|
||||
var pong2 = await SocketTestHelper.ReadUntilAsync(sock, "PONG", timeoutMs: 3000);
|
||||
pong2.ShouldContain("PONG");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Reads all data from the socket until the connection is closed or timeout elapses.
|
||||
/// This is more robust than ReadUntilAsync for cases where the server sends an error
|
||||
/// and immediately closes the connection — we want to capture everything sent.
|
||||
/// </summary>
|
||||
private static async Task<string> ReadAllBeforeCloseAsync(Socket sock, int timeoutMs = 5000)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
var sb = new StringBuilder();
|
||||
var buf = new byte[4096];
|
||||
while (true)
|
||||
{
|
||||
int n;
|
||||
try
|
||||
{
|
||||
n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (SocketException) { break; }
|
||||
if (n == 0) break; // Connection closed
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static bool ContainsInChain(Exception ex, string substring)
|
||||
{
|
||||
Exception? current = ex;
|
||||
while (current != null)
|
||||
{
|
||||
if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
current = current.InnerException;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Tests for ConfigReloader.ApplyClusterConfigChanges.
|
||||
// Go reference: golang/nats-server/server/reload.go — routesOption.Apply, gatewayOption.Apply.
|
||||
|
||||
using NATS.Server;
|
||||
using NATS.Server.Configuration;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Configuration;
|
||||
|
||||
public class ClusterConfigReloadTests
|
||||
{
|
||||
// ─── helpers ────────────────────────────────────────────────────
|
||||
|
||||
private static NatsOptions WithRoutes(params string[] routes)
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.Cluster = new ClusterOptions { Routes = [..routes] };
|
||||
return opts;
|
||||
}
|
||||
|
||||
private static NatsOptions WithGatewayRemotes(params string[] urls)
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.Gateway = new GatewayOptions
|
||||
{
|
||||
RemoteGateways = [new RemoteGatewayOptions { Urls = [..urls] }]
|
||||
};
|
||||
return opts;
|
||||
}
|
||||
|
||||
private static NatsOptions WithLeafRemotes(params string[] urls)
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.LeafNode = new LeafNodeOptions { Remotes = [..urls] };
|
||||
return opts;
|
||||
}
|
||||
|
||||
// ─── tests ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void No_changes_returns_no_changes()
|
||||
{
|
||||
// Go reference: reload.go routesOption.Apply — no-op when sets are equal
|
||||
var old = WithRoutes("nats://server1:6222", "nats://server2:6222");
|
||||
var updated = WithRoutes("nats://server1:6222", "nats://server2:6222");
|
||||
|
||||
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeFalse();
|
||||
result.RouteUrlsChanged.ShouldBeFalse();
|
||||
result.GatewayUrlsChanged.ShouldBeFalse();
|
||||
result.LeafUrlsChanged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Route_url_added_detected()
|
||||
{
|
||||
// Go reference: reload.go routesOption.Apply — new route triggers reconnect
|
||||
var old = WithRoutes("nats://server1:6222");
|
||||
var updated = WithRoutes("nats://server1:6222", "nats://server2:6222");
|
||||
|
||||
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.RouteUrlsChanged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Route_url_removed_detected()
|
||||
{
|
||||
// Go reference: reload.go routesOption.Apply — removed route triggers disconnect
|
||||
var old = WithRoutes("nats://server1:6222", "nats://server2:6222");
|
||||
var updated = WithRoutes("nats://server1:6222");
|
||||
|
||||
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.RouteUrlsChanged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gateway_url_changed_detected()
|
||||
{
|
||||
// Go reference: reload.go gatewayOption.Apply — gateway remotes reconciled on reload
|
||||
var old = WithGatewayRemotes("nats://gw1:7222");
|
||||
var updated = WithGatewayRemotes("nats://gw2:7222");
|
||||
|
||||
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.GatewayUrlsChanged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Leaf_url_changed_detected()
|
||||
{
|
||||
// Go reference: reload.go leafNodeOption.Apply — leaf remotes reconciled on reload
|
||||
var old = WithLeafRemotes("nats://hub:5222");
|
||||
var updated = WithLeafRemotes("nats://hub2:5222");
|
||||
|
||||
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.LeafUrlsChanged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_changes_detected()
|
||||
{
|
||||
// Go reference: reload.go — multiple topology changes in a single reload
|
||||
var old = new NatsOptions
|
||||
{
|
||||
Cluster = new ClusterOptions { Routes = ["nats://r1:6222"] },
|
||||
Gateway = new GatewayOptions { RemoteGateways = [new RemoteGatewayOptions { Urls = ["nats://gw1:7222"] }] }
|
||||
};
|
||||
var updated = new NatsOptions
|
||||
{
|
||||
Cluster = new ClusterOptions { Routes = ["nats://r1:6222", "nats://r2:6222"] },
|
||||
Gateway = new GatewayOptions { RemoteGateways = [new RemoteGatewayOptions { Urls = ["nats://gw2:7222"] }] }
|
||||
};
|
||||
|
||||
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.RouteUrlsChanged.ShouldBeTrue();
|
||||
result.GatewayUrlsChanged.ShouldBeTrue();
|
||||
result.LeafUrlsChanged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_urls_different_order_no_change()
|
||||
{
|
||||
// Go reference: reload.go — order-independent URL comparison
|
||||
var old = WithRoutes("nats://server1:6222", "nats://server2:6222");
|
||||
var updated = WithRoutes("nats://server2:6222", "nats://server1:6222");
|
||||
|
||||
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeFalse();
|
||||
result.RouteUrlsChanged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddedRouteUrls_lists_new_routes()
|
||||
{
|
||||
// Go reference: reload.go routesOption.Apply — identifies routes to dial
|
||||
var old = WithRoutes("nats://server1:6222");
|
||||
var updated = WithRoutes("nats://server1:6222", "nats://server2:6222", "nats://server3:6222");
|
||||
|
||||
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||
|
||||
result.AddedRouteUrls.Count.ShouldBe(2);
|
||||
result.AddedRouteUrls.ShouldContain("nats://server2:6222");
|
||||
result.AddedRouteUrls.ShouldContain("nats://server3:6222");
|
||||
result.RemovedRouteUrls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemovedRouteUrls_lists_removed_routes()
|
||||
{
|
||||
// Go reference: reload.go routesOption.Apply — identifies routes to close
|
||||
var old = WithRoutes("nats://server1:6222", "nats://server2:6222", "nats://server3:6222");
|
||||
var updated = WithRoutes("nats://server1:6222");
|
||||
|
||||
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||
|
||||
result.RemovedRouteUrls.Count.ShouldBe(2);
|
||||
result.RemovedRouteUrls.ShouldContain("nats://server2:6222");
|
||||
result.RemovedRouteUrls.ShouldContain("nats://server3:6222");
|
||||
result.AddedRouteUrls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_to_non_empty_detected()
|
||||
{
|
||||
// Go reference: reload.go routesOption.Apply — nil→populated triggers dial
|
||||
var old = new NatsOptions(); // no Cluster configured
|
||||
var updated = WithRoutes("nats://server1:6222");
|
||||
|
||||
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.RouteUrlsChanged.ShouldBeTrue();
|
||||
result.AddedRouteUrls.ShouldContain("nats://server1:6222");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Reflection;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Configuration;
|
||||
|
||||
public class ConfigPedanticParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseWithChecks_matches_parse_for_basic_input()
|
||||
{
|
||||
const string config = "port: 4222\nhost: 127.0.0.1\n";
|
||||
|
||||
var regular = NatsConfParser.Parse(config);
|
||||
var withChecks = NatsConfParser.ParseWithChecks(config);
|
||||
|
||||
withChecks["port"].ShouldBe(regular["port"]);
|
||||
withChecks["host"].ShouldBe(regular["host"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFileWithChecks_and_digest_wrappers_are_available_and_stable()
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, "port: 4222\n");
|
||||
|
||||
var parsed = NatsConfParser.ParseFileWithChecks(path);
|
||||
parsed["port"].ShouldBe(4222L);
|
||||
|
||||
var (cfg1, d1) = NatsConfParser.ParseFileWithChecksDigest(path);
|
||||
var (cfg2, d2) = NatsConfParser.ParseFileWithDigest(path);
|
||||
var (_, d1Repeat) = NatsConfParser.ParseFileWithChecksDigest(path);
|
||||
|
||||
cfg1["port"].ShouldBe(4222L);
|
||||
cfg2["port"].ShouldBe(4222L);
|
||||
d1.ShouldStartWith("sha256:");
|
||||
d2.ShouldStartWith("sha256:");
|
||||
d1.ShouldBe(d1Repeat);
|
||||
d1.ShouldNotBe(d2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PedanticToken_accessors_match_expected_values()
|
||||
{
|
||||
var token = new Token(TokenType.Integer, "42", 3, 7);
|
||||
var pedantic = new PedanticToken(token, value: 42L, usedVariable: true, sourceFile: "test.conf");
|
||||
|
||||
pedantic.Value().ShouldBe(42L);
|
||||
pedantic.Line().ShouldBe(3);
|
||||
pedantic.Position().ShouldBe(7);
|
||||
pedantic.IsUsedVariable().ShouldBeTrue();
|
||||
pedantic.SourceFile().ShouldBe("test.conf");
|
||||
pedantic.MarshalJson().ShouldBe("42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parser_exposes_pedantic_compatibility_hooks()
|
||||
{
|
||||
var parserType = typeof(NatsConfParser);
|
||||
parserType.GetMethod("CleanupUsedEnvVars", BindingFlags.NonPublic | BindingFlags.Static).ShouldNotBeNull();
|
||||
|
||||
var parserStateType = parserType.GetNestedType("ParserState", BindingFlags.NonPublic);
|
||||
parserStateType.ShouldNotBeNull();
|
||||
parserStateType!.GetMethod("PushItemKey", BindingFlags.NonPublic | BindingFlags.Instance).ShouldNotBeNull();
|
||||
parserStateType.GetMethod("PopItemKey", BindingFlags.NonPublic | BindingFlags.Instance).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcrypt_prefix_values_are_preserved_for_2a_and_2b()
|
||||
{
|
||||
var parsed2a = NatsConfParser.Parse("pwd: $2a$abc\n");
|
||||
var parsed2b = NatsConfParser.Parse("pwd: $2b$abc\n");
|
||||
|
||||
parsed2a["pwd"].ShouldBe("$2a$abc");
|
||||
parsed2b["pwd"].ShouldBe("$2b$abc");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
// Advanced configuration and reload tests for full Go parity.
|
||||
// Covers: CLI override precedence (opts_test.go TestMergeOverrides, TestConfigureOptions),
|
||||
// configuration defaults (opts_test.go TestDefaultOptions), configuration validation
|
||||
// (opts_test.go TestMalformedListenAddress, TestMaxClosedClients), NatsOptions model
|
||||
// defaults, ConfigProcessor parsing, ConfigReloader diff/validate semantics, and
|
||||
// reload scenarios not covered by ConfigReloadExtendedParityTests.
|
||||
// Reference: golang/nats-server/server/opts_test.go, reload_test.go
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Advanced configuration model and hot-reload tests ported from Go's opts_test.go
|
||||
/// and reload_test.go. Focuses on: NatsOptions defaults, ConfigProcessor parsing,
|
||||
/// ConfigReloader diff/validate, CLI-override precedence, and reload-time validation
|
||||
/// paths not exercised by the basic and extended parity suites.
|
||||
/// </summary>
|
||||
public class ConfigReloadAdvancedTests
|
||||
{
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<Socket> RawConnectAsync(int port)
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||||
var buf = new byte[4096];
|
||||
await sock.ReceiveAsync(buf, SocketFlags.None);
|
||||
return sock;
|
||||
}
|
||||
|
||||
private static void WriteConfigAndReload(NatsServer server, string configPath, string configText)
|
||||
{
|
||||
File.WriteAllText(configPath, configText);
|
||||
server.ReloadConfigOrThrow();
|
||||
}
|
||||
|
||||
private static async Task<(NatsServer server, int port, CancellationTokenSource cts, string configPath)>
|
||||
StartServerWithConfigAsync(string configContent)
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-adv-{Guid.NewGuid():N}.conf");
|
||||
var finalContent = configContent.Replace("{PORT}", port.ToString());
|
||||
File.WriteAllText(configPath, finalContent);
|
||||
|
||||
var options = new NatsOptions { ConfigFile = configPath, Port = port };
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, port, cts, configPath);
|
||||
}
|
||||
|
||||
private static async Task CleanupAsync(NatsServer server, CancellationTokenSource cts, string configPath)
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
|
||||
// ─── Tests: NatsOptions Default Values ──────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestDefaultOptions opts_test.go:52
|
||||
/// NatsOptions must be constructed with the correct NATS protocol defaults.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NatsOptions_default_port_is_4222()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.Port.ShouldBe(4222);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestDefaultOptions opts_test.go:52
|
||||
/// Default host must be the wildcard address to listen on all interfaces.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NatsOptions_default_host_is_wildcard()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.Host.ShouldBe("0.0.0.0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestDefaultOptions opts_test.go:52 (MaxConn = DEFAULT_MAX_CONNECTIONS = 65536)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NatsOptions_default_max_connections_is_65536()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.MaxConnections.ShouldBe(65536);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestDefaultOptions opts_test.go:52 (MaxPayload = MAX_PAYLOAD_SIZE = 1MB)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NatsOptions_default_max_payload_is_1_megabyte()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.MaxPayload.ShouldBe(1024 * 1024);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestDefaultOptions opts_test.go:52 (MaxControlLine = MAX_CONTROL_LINE_SIZE = 4096)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NatsOptions_default_max_control_line_is_4096()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.MaxControlLine.ShouldBe(4096);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestDefaultOptions opts_test.go:52 (PingInterval = DEFAULT_PING_INTERVAL = 2m)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NatsOptions_default_ping_interval_is_two_minutes()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.PingInterval.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestDefaultOptions opts_test.go:52 (MaxPingsOut = DEFAULT_PING_MAX_OUT = 2)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NatsOptions_default_max_pings_out_is_2()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.MaxPingsOut.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestDefaultOptions opts_test.go:52 (AuthTimeout = AUTH_TIMEOUT = 2s)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NatsOptions_default_auth_timeout_is_two_seconds()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestDefaultOptions opts_test.go:52 (WriteDeadline = DEFAULT_FLUSH_DEADLINE = 10s)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NatsOptions_default_write_deadline_is_ten_seconds()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestDefaultOptions opts_test.go:52 (ConnectErrorReports = 3600)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NatsOptions_default_connect_error_reports()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.ConnectErrorReports.ShouldBe(3600);
|
||||
}
|
||||
|
||||
// ─── Tests: ConfigProcessor Parsing ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigFile opts_test.go:97 — parsed config overrides default port.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_port()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("port: 14222");
|
||||
opts.Port.ShouldBe(14222);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigFile opts_test.go:97 — parsed config sets host.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_host()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("host: 127.0.0.1");
|
||||
opts.Host.ShouldBe("127.0.0.1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigFile opts_test.go:97 — parsed config sets server_name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_server_name()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("server_name: my-server");
|
||||
opts.ServerName.ShouldBe("my-server");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigFile opts_test.go:97 — debug/trace flags parsed from config.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_debug_and_trace()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("debug: true\ntrace: true");
|
||||
opts.Debug.ShouldBeTrue();
|
||||
opts.Trace.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigFile opts_test.go:97 — max_payload parsed from config.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_max_payload()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("max_payload: 65536");
|
||||
opts.MaxPayload.ShouldBe(65536);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestPingIntervalNew opts_test.go:1369 — ping_interval parsed as duration string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_ping_interval_duration_string()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("ping_interval: \"60s\"");
|
||||
opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestParseWriteDeadline opts_test.go:1187 — write_deadline as "Xs" duration string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_write_deadline_duration_string()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("write_deadline: \"3s\"");
|
||||
opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestMalformedListenAddress opts_test.go:1314
|
||||
/// A malformed listen address must produce a parsing exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_rejects_malformed_listen_address()
|
||||
{
|
||||
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfig("listen: \":not-a-port\""));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestEmptyConfig opts_test.go:1302
|
||||
/// An empty config file must produce options with all default values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_empty_config_produces_defaults()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("");
|
||||
opts.Port.ShouldBe(4222);
|
||||
opts.Host.ShouldBe("0.0.0.0");
|
||||
opts.MaxPayload.ShouldBe(1024 * 1024);
|
||||
opts.MaxConnections.ShouldBe(65536);
|
||||
}
|
||||
|
||||
// ─── Tests: ConfigReloader Diff / Validate ──────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigReloadUnsupportedHotSwapping reload_test.go:180
|
||||
/// ConfigReloader.Diff must detect port change as non-reloadable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigReloader_diff_detects_port_change_as_non_reloadable()
|
||||
{
|
||||
var oldOpts = new NatsOptions { Port = 4222 };
|
||||
var newOpts = new NatsOptions { Port = 5555 };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var portChange = changes.FirstOrDefault(c => c.Name == "Port");
|
||||
|
||||
portChange.ShouldNotBeNull();
|
||||
portChange!.IsNonReloadable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigReload reload_test.go:251 — debug flag diff correctly categorised.
|
||||
/// ConfigReloader.Diff must categorise debug change as a logging change.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigReloader_diff_categorises_debug_as_logging_change()
|
||||
{
|
||||
var oldOpts = new NatsOptions { Debug = false };
|
||||
var newOpts = new NatsOptions { Debug = true };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var debugChange = changes.FirstOrDefault(c => c.Name == "Debug");
|
||||
|
||||
debugChange.ShouldNotBeNull();
|
||||
debugChange!.IsLoggingChange.ShouldBeTrue();
|
||||
debugChange.IsNonReloadable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigReloadRotateUserAuthentication reload_test.go:658
|
||||
/// ConfigReloader.Diff must categorise username/password change as an auth change.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigReloader_diff_categorises_username_as_auth_change()
|
||||
{
|
||||
var oldOpts = new NatsOptions { Username = "alice" };
|
||||
var newOpts = new NatsOptions { Username = "bob" };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var usernameChange = changes.FirstOrDefault(c => c.Name == "Username");
|
||||
|
||||
usernameChange.ShouldNotBeNull();
|
||||
usernameChange!.IsAuthChange.ShouldBeTrue();
|
||||
usernameChange.IsNonReloadable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigReload reload_test.go:251
|
||||
/// ConfigReloader.Diff on identical options must return an empty change list.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigReloader_diff_on_identical_options_returns_empty()
|
||||
{
|
||||
var opts = new NatsOptions { Port = 4222, Debug = false, MaxPayload = 1024 * 1024 };
|
||||
var same = new NatsOptions { Port = 4222, Debug = false, MaxPayload = 1024 * 1024 };
|
||||
|
||||
var changes = ConfigReloader.Diff(opts, same);
|
||||
changes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigReloadClusterPortUnsupported reload_test.go:1394
|
||||
/// ConfigReloader.Diff must detect cluster port change as non-reloadable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigReloader_diff_detects_cluster_port_change_as_non_reloadable()
|
||||
{
|
||||
var oldOpts = new NatsOptions { Cluster = new ClusterOptions { Host = "127.0.0.1", Port = 6222 } };
|
||||
var newOpts = new NatsOptions { Cluster = new ClusterOptions { Host = "127.0.0.1", Port = 7777 } };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var clusterChange = changes.FirstOrDefault(c => c.Name == "Cluster");
|
||||
|
||||
clusterChange.ShouldNotBeNull();
|
||||
clusterChange!.IsNonReloadable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: reload_test.go — JetStream.StoreDir change must be non-reloadable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigReloader_diff_detects_jetstream_store_dir_change_as_non_reloadable()
|
||||
{
|
||||
var oldOpts = new NatsOptions { JetStream = new JetStreamOptions { StoreDir = "/tmp/js1" } };
|
||||
var newOpts = new NatsOptions { JetStream = new JetStreamOptions { StoreDir = "/tmp/js2" } };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var jsDirChange = changes.FirstOrDefault(c => c.Name == "JetStream.StoreDir");
|
||||
|
||||
jsDirChange.ShouldNotBeNull();
|
||||
jsDirChange!.IsNonReloadable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ConfigReloader.Validate must return errors for all non-reloadable changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigReloader_validate_returns_errors_for_non_reloadable_changes()
|
||||
{
|
||||
var oldOpts = new NatsOptions { Port = 4222 };
|
||||
var newOpts = new NatsOptions { Port = 9999 };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var errors = ConfigReloader.Validate(changes);
|
||||
|
||||
errors.ShouldNotBeEmpty();
|
||||
errors.ShouldContain(e => e.Contains("Port", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// ─── Tests: CLI Override Precedence ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestMergeOverrides opts_test.go:264
|
||||
/// ConfigReloader.MergeCliOverrides must restore the CLI port value after a
|
||||
/// config reload that tries to set a different port.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigReloader_merge_cli_overrides_restores_port()
|
||||
{
|
||||
// Simulate: CLI sets port=14222; config file says port=9999.
|
||||
var cliValues = new NatsOptions { Port = 14222 };
|
||||
var cliFlags = new HashSet<string> { "Port" };
|
||||
var fromConfig = new NatsOptions { Port = 9999 };
|
||||
|
||||
ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags);
|
||||
|
||||
fromConfig.Port.ShouldBe(14222);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestMergeOverrides opts_test.go:264
|
||||
/// CLI debug=true must override config debug=false after merge.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigReloader_merge_cli_overrides_restores_debug_flag()
|
||||
{
|
||||
var cliValues = new NatsOptions { Debug = true };
|
||||
var cliFlags = new HashSet<string> { "Debug" };
|
||||
var fromConfig = new NatsOptions { Debug = false };
|
||||
|
||||
ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags);
|
||||
|
||||
fromConfig.Debug.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestMergeOverrides opts_test.go:264
|
||||
/// A flag not present in cliFlags must not override the config value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigReloader_merge_cli_overrides_ignores_non_cli_fields()
|
||||
{
|
||||
var cliValues = new NatsOptions { MaxPayload = 512 };
|
||||
// MaxPayload is NOT in cliFlags — it came from config, not CLI.
|
||||
var cliFlags = new HashSet<string> { "Port" };
|
||||
var fromConfig = new NatsOptions { MaxPayload = 1024 * 1024 };
|
||||
|
||||
ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags);
|
||||
|
||||
// MaxPayload should remain the config-file value, not the CLI stub value.
|
||||
fromConfig.MaxPayload.ShouldBe(1024 * 1024);
|
||||
}
|
||||
|
||||
// ─── Tests: Config File Parsing Round-Trip ──────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigFile opts_test.go:97 — max_connections parsed and accessible.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_max_connections()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("max_connections: 100");
|
||||
opts.MaxConnections.ShouldBe(100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigFile opts_test.go:97 — lame_duck_duration parsed from config.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_lame_duck_duration()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("lame_duck_duration: \"4m\"");
|
||||
opts.LameDuckDuration.ShouldBe(TimeSpan.FromMinutes(4));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestMaxClosedClients opts_test.go:1340 — max_closed_clients parsed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_max_closed_clients()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("max_closed_clients: 500");
|
||||
opts.MaxClosedClients.ShouldBe(500);
|
||||
}
|
||||
|
||||
// ─── Tests: Reload Host Change Rejected ────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigReloadUnsupportedHotSwapping reload_test.go:180
|
||||
/// Changing the listen host must be rejected at reload time.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reload_host_change_rejected()
|
||||
{
|
||||
var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(configPath, $"port: {port}\nhost: 127.0.0.1");
|
||||
Should.Throw<InvalidOperationException>(() => server.ReloadConfigOrThrow())
|
||||
.Message.ShouldContain("Host");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CleanupAsync(server, cts, configPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests: Reload TLS Settings ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Reloading with allow_non_tls must succeed and not disconnect existing clients.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reload_allow_non_tls_setting()
|
||||
{
|
||||
var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}");
|
||||
try
|
||||
{
|
||||
WriteConfigAndReload(server, configPath, $"port: {port}\nallow_non_tls: true");
|
||||
|
||||
await using var client = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" });
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CleanupAsync(server, cts, configPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests: Reload Cluster Name Change ─────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Go: TestConfigReloadClusterName reload_test.go:1893
|
||||
/// Adding a cluster block for the first time is a non-reloadable change.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reload_adding_cluster_block_rejected()
|
||||
{
|
||||
var clusterPort = TestPortAllocator.GetFreePort();
|
||||
var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(configPath,
|
||||
$"port: {port}\ncluster {{\n name: new-cluster\n host: 127.0.0.1\n port: {clusterPort}\n}}");
|
||||
Should.Throw<InvalidOperationException>(() => server.ReloadConfigOrThrow())
|
||||
.Message.ShouldContain("Cluster");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CleanupAsync(server, cts, configPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests: JetStream Options Model ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// JetStreamOptions must have sensible defaults (StoreDir empty, all limits 0).
|
||||
/// Go: server/opts.go JetStreamConfig defaults.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void JetStreamOptions_defaults_are_empty_and_unlimited()
|
||||
{
|
||||
var jsOpts = new JetStreamOptions();
|
||||
jsOpts.StoreDir.ShouldBe(string.Empty);
|
||||
jsOpts.MaxMemoryStore.ShouldBe(0L);
|
||||
jsOpts.MaxFileStore.ShouldBe(0L);
|
||||
jsOpts.MaxStreams.ShouldBe(0);
|
||||
jsOpts.MaxConsumers.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ConfigProcessor must correctly parse a jetstream block with store_dir.
|
||||
/// Go: server/opts.go parseJetStream.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_parses_jetstream_store_dir()
|
||||
{
|
||||
var storeDir = Path.Combine(Path.GetTempPath(), $"nats-js-parse-{Guid.NewGuid():N}");
|
||||
var opts = ConfigProcessor.ProcessConfig(
|
||||
$"jetstream {{\n store_dir: \"{storeDir.Replace("\\", "\\\\")}\"\n}}");
|
||||
|
||||
opts.JetStream.ShouldNotBeNull();
|
||||
opts.JetStream!.StoreDir.ShouldBe(storeDir);
|
||||
}
|
||||
|
||||
// ─── Tests: Reload max_sub_tokens Validation ────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Go: opts_test.go (max_sub_tokens validation) — ConfigProcessor must reject
|
||||
/// max_sub_tokens values that exceed 256.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_rejects_max_sub_tokens_above_256()
|
||||
{
|
||||
Should.Throw<ConfigProcessorException>(() =>
|
||||
ConfigProcessor.ProcessConfig("max_sub_tokens: 300"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ConfigProcessor must accept max_sub_tokens values of exactly 256.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_accepts_max_sub_tokens_at_boundary_256()
|
||||
{
|
||||
var opts = ConfigProcessor.ProcessConfig("max_sub_tokens: 256");
|
||||
opts.MaxSubTokens.ShouldBe(256);
|
||||
}
|
||||
|
||||
// ─── Tests: server_name with spaces ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Go: opts_test.go server_name validation — server names containing spaces
|
||||
/// must be rejected by the config processor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConfigProcessor_rejects_server_name_with_spaces()
|
||||
{
|
||||
Should.Throw<ConfigProcessorException>(() =>
|
||||
ConfigProcessor.ProcessConfig("server_name: \"my server\""));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
||||
// Port of Go server/reload_test.go — TestConfigReloadMaxConnections,
|
||||
// TestConfigReloadEnableUserAuthentication, TestConfigReloadDisableUserAuthentication,
|
||||
// and connection-survival during reload.
|
||||
// Reference: golang/nats-server/server/reload_test.go lines 1978, 720, 781.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Parity tests for config hot reload behaviour.
|
||||
/// Covers the three scenarios from Go's reload_test.go:
|
||||
/// - MaxConnections reduction takes effect on new connections
|
||||
/// - Enabling authentication rejects new unauthorised connections
|
||||
/// - Existing connections survive a benign (logging) config reload
|
||||
/// </summary>
|
||||
public class ConfigReloadParityTests
|
||||
{
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, port, cts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects a raw TCP client and reads the initial INFO line.
|
||||
/// Returns the connected socket (caller owns disposal).
|
||||
/// </summary>
|
||||
private static async Task<Socket> RawConnectAsync(int port)
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||||
|
||||
// Drain the INFO line so subsequent reads start at the NATS protocol layer.
|
||||
var buf = new byte[4096];
|
||||
await sock.ReceiveAsync(buf, SocketFlags.None);
|
||||
return sock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads from <paramref name="sock"/> until the accumulated response contains
|
||||
/// <paramref name="expected"/> or the timeout elapses.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Writes a config file, then calls <see cref="NatsServer.ReloadConfigOrThrow"/>.
|
||||
/// Mirrors the pattern from JetStreamClusterReloadTests.
|
||||
/// </summary>
|
||||
private static void WriteConfigAndReload(NatsServer server, string configPath, string configText)
|
||||
{
|
||||
File.WriteAllText(configPath, configText);
|
||||
server.ReloadConfigOrThrow();
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Port of Go TestConfigReloadMaxConnections (reload_test.go:1978).
|
||||
///
|
||||
/// Verifies that reducing MaxConnections via hot reload causes the server to
|
||||
/// reject new connections that would exceed the new limit. The .NET server
|
||||
/// enforces the limit at accept-time, so existing connections are preserved
|
||||
/// while future ones beyond the cap receive a -ERR response.
|
||||
///
|
||||
/// Go reference: max_connections.conf sets max_connections: 1 and the Go
|
||||
/// server then closes one existing client; the .NET implementation rejects
|
||||
/// new connections instead of kicking established ones.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reload_max_connections_takes_effect()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-maxconn-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
// Allocate a port first so we can embed it in the config file.
|
||||
// The server will bind to this port; the config file must match
|
||||
// to avoid a non-reloadable Port-change error on reload.
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
|
||||
// Start with no connection limit.
|
||||
File.WriteAllText(configPath, $"port: {port}\nmax_connections: 65536");
|
||||
|
||||
var options = new NatsOptions { ConfigFile = configPath, Port = port };
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Establish two raw connections before limiting.
|
||||
using var c1 = await RawConnectAsync(port);
|
||||
using var c2 = await RawConnectAsync(port);
|
||||
|
||||
server.ClientCount.ShouldBe(2);
|
||||
|
||||
// Reload with MaxConnections = 2 (equal to current count).
|
||||
// New connections beyond this cap must be rejected.
|
||||
WriteConfigAndReload(server, configPath, $"port: {port}\nmax_connections: 2");
|
||||
|
||||
// Verify the limit is now in effect: a third connection should be
|
||||
// rejected with -ERR 'maximum connections exceeded'.
|
||||
using var c3 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await c3.ConnectAsync(IPAddress.Loopback, port);
|
||||
|
||||
// The server sends INFO then immediately -ERR and closes the socket.
|
||||
var response = await SocketTestHelper.ReadUntilAsync(c3, "-ERR", timeoutMs: 5000);
|
||||
response.ShouldContain("maximum connections exceeded");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of Go TestConfigReloadEnableUserAuthentication (reload_test.go:720).
|
||||
///
|
||||
/// Verifies that enabling username/password authentication via hot reload
|
||||
/// causes new unauthenticated connections to be rejected with an
|
||||
/// "Authorization Violation" error, while connections using the new
|
||||
/// credentials succeed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reload_auth_changes_take_effect()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-auth-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
// Allocate a port and embed it in every config write to prevent a
|
||||
// non-reloadable Port-change error when the config file is updated.
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
|
||||
// Start with no authentication required.
|
||||
File.WriteAllText(configPath, $"port: {port}\ndebug: false");
|
||||
|
||||
var options = new NatsOptions { ConfigFile = configPath, Port = port };
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Confirm a connection works with no credentials.
|
||||
await using var preReloadClient = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{port}",
|
||||
});
|
||||
await preReloadClient.ConnectAsync();
|
||||
await preReloadClient.PingAsync();
|
||||
|
||||
// Reload with user/password authentication enabled.
|
||||
WriteConfigAndReload(server, configPath,
|
||||
$"port: {port}\nauthorization {{\n user: tyler\n password: T0pS3cr3t\n}}");
|
||||
|
||||
// New connections without credentials must be rejected.
|
||||
await using var noAuthClient = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{port}",
|
||||
MaxReconnectRetry = 0,
|
||||
});
|
||||
|
||||
var ex = await Should.ThrowAsync<NatsException>(async () =>
|
||||
{
|
||||
await noAuthClient.ConnectAsync();
|
||||
await noAuthClient.PingAsync();
|
||||
});
|
||||
|
||||
ContainsInChain(ex, "Authorization Violation").ShouldBeTrue(
|
||||
$"Expected 'Authorization Violation' in exception chain, but got: {ex}");
|
||||
|
||||
// New connections with the correct credentials must succeed.
|
||||
await using var authClient = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://tyler:T0pS3cr3t@127.0.0.1:{port}",
|
||||
});
|
||||
await authClient.ConnectAsync();
|
||||
await authClient.PingAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of Go TestConfigReloadDisableUserAuthentication (reload_test.go:781).
|
||||
///
|
||||
/// Verifies that disabling authentication via hot reload allows new
|
||||
/// connections without credentials to succeed. Also verifies that
|
||||
/// connections established before the reload survive the reload cycle
|
||||
/// (the server must not close healthy clients on a logging-only reload).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reload_preserves_existing_connections()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-preserve-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
// Allocate a port and embed it in every config write to prevent a
|
||||
// non-reloadable Port-change error when the config file is updated.
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
|
||||
// Start with debug disabled.
|
||||
File.WriteAllText(configPath, $"port: {port}\ndebug: false");
|
||||
|
||||
var options = new NatsOptions { ConfigFile = configPath, Port = port };
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Establish a connection before the reload.
|
||||
await using var client = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{port}",
|
||||
});
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
|
||||
// The connection should be alive before reload.
|
||||
client.ConnectionState.ShouldBe(NatsConnectionState.Open);
|
||||
|
||||
// Reload with a logging-only change (debug flag); this must not
|
||||
// disconnect existing clients.
|
||||
WriteConfigAndReload(server, configPath, $"port: {port}\ndebug: true");
|
||||
|
||||
// Give the server a moment to apply changes.
|
||||
await Task.Delay(100);
|
||||
|
||||
// The pre-reload connection should still be alive.
|
||||
client.ConnectionState.ShouldBe(NatsConnectionState.Open,
|
||||
"Existing connection should survive a logging-only config reload");
|
||||
|
||||
// Verify the connection is still functional.
|
||||
await client.PingAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private helpers ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether any exception in the chain contains the given substring,
|
||||
/// matching the pattern used in AuthIntegrationTests.
|
||||
/// </summary>
|
||||
private static bool ContainsInChain(Exception ex, string substring)
|
||||
{
|
||||
Exception? current = ex;
|
||||
while (current != null)
|
||||
{
|
||||
if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
current = current.InnerException;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Configuration;
|
||||
|
||||
public class ConfigWarningsParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Config_warning_types_expose_message_and_source()
|
||||
{
|
||||
var warning = new ConfigWarningException("warn", "conf:1:2");
|
||||
var unknown = new UnknownConfigFieldWarning("mystery_field", "conf:3:1");
|
||||
|
||||
warning.Message.ShouldBe("warn");
|
||||
warning.SourceLocation.ShouldBe("conf:1:2");
|
||||
unknown.Field.ShouldBe("mystery_field");
|
||||
unknown.SourceLocation.ShouldBe("conf:3:1");
|
||||
unknown.Message.ShouldContain("unknown field");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessConfig_collects_unknown_field_warnings_when_errors_are_present()
|
||||
{
|
||||
var ex = Should.Throw<ConfigProcessorException>(() => ConfigProcessor.ProcessConfig("""
|
||||
max_sub_tokens: 300
|
||||
totally_unknown_field: 1
|
||||
"""));
|
||||
|
||||
ex.Errors.ShouldNotBeEmpty();
|
||||
ex.Warnings.ShouldContain(w => w.Contains("unknown field totally_unknown_field", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Port of Go server/reload.go — JetStream config change detection tests.
|
||||
// Reference: golang/nats-server/server/reload.go — jetStreamOption.Apply.
|
||||
|
||||
using NATS.Server.Configuration;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Configuration;
|
||||
|
||||
public sealed class JetStreamConfigReloadTests
|
||||
{
|
||||
// ─── helpers ────────────────────────────────────────────────────
|
||||
|
||||
private static NatsOptions BaseOpts() => new();
|
||||
|
||||
private static NatsOptions OptsWithJs(long maxMemory = 0, long maxStore = 0, string? domain = null) =>
|
||||
new()
|
||||
{
|
||||
JetStream = new JetStreamOptions
|
||||
{
|
||||
MaxMemoryStore = maxMemory,
|
||||
MaxFileStore = maxStore,
|
||||
Domain = domain
|
||||
}
|
||||
};
|
||||
|
||||
// ─── tests ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void No_changes_returns_no_changes()
|
||||
{
|
||||
// Identical JetStream config on both sides → no changes detected.
|
||||
var oldOpts = OptsWithJs(maxMemory: 512, maxStore: 1024, domain: "hub");
|
||||
var newOpts = OptsWithJs(maxMemory: 512, maxStore: 1024, domain: "hub");
|
||||
|
||||
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
|
||||
|
||||
result.HasChanges.ShouldBeFalse();
|
||||
result.MaxMemoryChanged.ShouldBeFalse();
|
||||
result.MaxStoreChanged.ShouldBeFalse();
|
||||
result.DomainChanged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxMemory_changed_detected()
|
||||
{
|
||||
// Changing MaxMemoryStore must set MaxMemoryChanged and HasChanges.
|
||||
var oldOpts = OptsWithJs(maxMemory: 1024 * 1024);
|
||||
var newOpts = OptsWithJs(maxMemory: 2 * 1024 * 1024);
|
||||
|
||||
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
|
||||
|
||||
result.MaxMemoryChanged.ShouldBeTrue();
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.MaxStoreChanged.ShouldBeFalse();
|
||||
result.DomainChanged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxStore_changed_detected()
|
||||
{
|
||||
// Changing MaxFileStore must set MaxStoreChanged and HasChanges.
|
||||
var oldOpts = OptsWithJs(maxStore: 10 * 1024 * 1024);
|
||||
var newOpts = OptsWithJs(maxStore: 20 * 1024 * 1024);
|
||||
|
||||
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
|
||||
|
||||
result.MaxStoreChanged.ShouldBeTrue();
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.MaxMemoryChanged.ShouldBeFalse();
|
||||
result.DomainChanged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Domain_changed_detected()
|
||||
{
|
||||
// Changing the domain name must set DomainChanged and HasChanges.
|
||||
var oldOpts = OptsWithJs(domain: "hub");
|
||||
var newOpts = OptsWithJs(domain: "edge");
|
||||
|
||||
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
|
||||
|
||||
result.DomainChanged.ShouldBeTrue();
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.MaxMemoryChanged.ShouldBeFalse();
|
||||
result.MaxStoreChanged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldMaxMemory_and_NewMaxMemory_set()
|
||||
{
|
||||
// When MaxMemoryStore changes the old and new values must be captured.
|
||||
var oldOpts = OptsWithJs(maxMemory: 100_000);
|
||||
var newOpts = OptsWithJs(maxMemory: 200_000);
|
||||
|
||||
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
|
||||
|
||||
result.OldMaxMemory.ShouldBe(100_000L);
|
||||
result.NewMaxMemory.ShouldBe(200_000L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldMaxStore_and_NewMaxStore_set()
|
||||
{
|
||||
// When MaxFileStore changes the old and new values must be captured.
|
||||
var oldOpts = OptsWithJs(maxStore: 500_000);
|
||||
var newOpts = OptsWithJs(maxStore: 1_000_000);
|
||||
|
||||
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
|
||||
|
||||
result.OldMaxStore.ShouldBe(500_000L);
|
||||
result.NewMaxStore.ShouldBe(1_000_000L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldDomain_and_NewDomain_set()
|
||||
{
|
||||
// When Domain changes both old and new values must be captured.
|
||||
var oldOpts = OptsWithJs(domain: "alpha");
|
||||
var newOpts = OptsWithJs(domain: "beta");
|
||||
|
||||
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
|
||||
|
||||
result.OldDomain.ShouldBe("alpha");
|
||||
result.NewDomain.ShouldBe("beta");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_changes_detected()
|
||||
{
|
||||
// Changing MaxMemory, MaxStore, and Domain together must flag all three.
|
||||
var oldOpts = OptsWithJs(maxMemory: 1024, maxStore: 2048, domain: "primary");
|
||||
var newOpts = OptsWithJs(maxMemory: 4096, maxStore: 8192, domain: "secondary");
|
||||
|
||||
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
|
||||
|
||||
result.MaxMemoryChanged.ShouldBeTrue();
|
||||
result.MaxStoreChanged.ShouldBeTrue();
|
||||
result.DomainChanged.ShouldBeTrue();
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_to_nonzero_memory_detected()
|
||||
{
|
||||
// Going from the default (0 = unlimited) to a concrete limit must be detected.
|
||||
var oldOpts = BaseOpts(); // JetStream is null → effective MaxMemoryStore = 0
|
||||
var newOpts = OptsWithJs(maxMemory: 512 * 1024 * 1024);
|
||||
|
||||
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
|
||||
|
||||
result.MaxMemoryChanged.ShouldBeTrue();
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.OldMaxMemory.ShouldBe(0L);
|
||||
result.NewMaxMemory.ShouldBe(512 * 1024 * 1024L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Domain_null_to_value_detected()
|
||||
{
|
||||
// Going from no JetStream config (domain null/empty) to a named domain must be detected.
|
||||
var oldOpts = BaseOpts(); // JetStream is null → effective domain = ""
|
||||
var newOpts = OptsWithJs(domain: "cloud");
|
||||
|
||||
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
|
||||
|
||||
result.DomainChanged.ShouldBeTrue();
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.OldDomain.ShouldBe(string.Empty);
|
||||
result.NewDomain.ShouldBe("cloud");
|
||||
}
|
||||
}
|
||||
169
tests/NATS.Server.Core.Tests/Configuration/LoggingReloadTests.cs
Normal file
169
tests/NATS.Server.Core.Tests/Configuration/LoggingReloadTests.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
// Tests for ConfigReloader.ApplyLoggingChanges.
|
||||
// Go reference: golang/nats-server/server/reload.go — traceOption.Apply, debugOption.Apply.
|
||||
|
||||
using NATS.Server;
|
||||
using NATS.Server.Configuration;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Configuration;
|
||||
|
||||
public class LoggingReloadTests
|
||||
{
|
||||
// ─── helpers ────────────────────────────────────────────────────
|
||||
|
||||
private static NatsOptions BaseOpts() => new NatsOptions();
|
||||
|
||||
// ─── tests ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void No_changes_returns_no_changes()
|
||||
{
|
||||
// Go reference: reload.go traceOption.Apply — no-op when flags unchanged
|
||||
var old = BaseOpts();
|
||||
var updated = BaseOpts();
|
||||
|
||||
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeFalse();
|
||||
result.LevelChanged.ShouldBeFalse();
|
||||
result.TraceChanged.ShouldBeFalse();
|
||||
result.DebugChanged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Level_changed_detected()
|
||||
{
|
||||
// Go reference: reload.go debugOption.Apply — enabling debug changes effective level
|
||||
var old = BaseOpts(); // Debug=false, Trace=false → "Information"
|
||||
var updated = BaseOpts();
|
||||
updated.Debug = true; // Debug=true → "Debug"
|
||||
|
||||
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.LevelChanged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trace_enabled_detected()
|
||||
{
|
||||
// Go reference: reload.go traceOption.Apply — enabling trace flag
|
||||
var old = BaseOpts();
|
||||
var updated = BaseOpts();
|
||||
updated.Trace = true;
|
||||
|
||||
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.TraceChanged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trace_disabled_detected()
|
||||
{
|
||||
// Go reference: reload.go traceOption.Apply — disabling trace flag
|
||||
var old = BaseOpts();
|
||||
old.Trace = true;
|
||||
var updated = BaseOpts(); // Trace=false
|
||||
|
||||
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.TraceChanged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Debug_enabled_detected()
|
||||
{
|
||||
// Go reference: reload.go debugOption.Apply — enabling debug flag
|
||||
var old = BaseOpts();
|
||||
var updated = BaseOpts();
|
||||
updated.Debug = true;
|
||||
|
||||
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.DebugChanged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Debug_disabled_detected()
|
||||
{
|
||||
// Go reference: reload.go debugOption.Apply — disabling debug flag
|
||||
var old = BaseOpts();
|
||||
old.Debug = true;
|
||||
var updated = BaseOpts(); // Debug=false
|
||||
|
||||
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.DebugChanged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldLevel_and_NewLevel_set()
|
||||
{
|
||||
// Go reference: reload.go — level transition is reported with explicit before/after values
|
||||
var old = BaseOpts(); // Information
|
||||
var updated = BaseOpts();
|
||||
updated.Trace = true; // Trace takes precedence
|
||||
|
||||
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
|
||||
|
||||
result.LevelChanged.ShouldBeTrue();
|
||||
result.OldLevel.ShouldBe("Information");
|
||||
result.NewLevel.ShouldBe("Trace");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Case_insensitive_level_comparison()
|
||||
{
|
||||
// Same effective level produced regardless of flag combination that yields the same tier
|
||||
// Debug=true on both sides → "Debug" == "Debug", no level change
|
||||
var old = BaseOpts();
|
||||
old.Debug = true;
|
||||
var updated = BaseOpts();
|
||||
updated.Debug = true;
|
||||
|
||||
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
|
||||
|
||||
result.LevelChanged.ShouldBeFalse();
|
||||
result.HasChanges.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_level_defaults_to_Information()
|
||||
{
|
||||
// Go reference: reload.go — absent log level is treated as Information
|
||||
// When neither Debug nor Trace is set the effective level is "Information"
|
||||
var old = BaseOpts();
|
||||
var updated = BaseOpts();
|
||||
|
||||
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
|
||||
|
||||
// No change, and effective level should be "Information" for both sides
|
||||
result.LevelChanged.ShouldBeFalse();
|
||||
result.OldLevel.ShouldBeNull(); // only populated when a level change is detected
|
||||
result.NewLevel.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_changes_detected()
|
||||
{
|
||||
// Go reference: reload.go — independent trace and debug options both apply
|
||||
var old = BaseOpts();
|
||||
old.Debug = true;
|
||||
|
||||
var updated = BaseOpts();
|
||||
updated.Trace = true; // Debug removed, Trace added — both flags changed
|
||||
|
||||
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
|
||||
|
||||
result.HasChanges.ShouldBeTrue();
|
||||
result.TraceChanged.ShouldBeTrue();
|
||||
result.DebugChanged.ShouldBeTrue();
|
||||
result.LevelChanged.ShouldBeTrue();
|
||||
result.OldLevel.ShouldBe("Debug");
|
||||
result.NewLevel.ShouldBe("Trace");
|
||||
}
|
||||
}
|
||||
2553
tests/NATS.Server.Core.Tests/Configuration/OptsGoParityTests.cs
Normal file
2553
tests/NATS.Server.Core.Tests/Configuration/OptsGoParityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
1018
tests/NATS.Server.Core.Tests/Configuration/ReloadGoParityTests.cs
Normal file
1018
tests/NATS.Server.Core.Tests/Configuration/ReloadGoParityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
372
tests/NATS.Server.Core.Tests/Configuration/SignalReloadTests.cs
Normal file
372
tests/NATS.Server.Core.Tests/Configuration/SignalReloadTests.cs
Normal file
@@ -0,0 +1,372 @@
|
||||
// Port of Go server/reload_test.go — TestConfigReloadSIGHUP, TestReloadAsync,
|
||||
// TestApplyDiff, TestReloadConfigOrThrow.
|
||||
// Reference: golang/nats-server/server/reload_test.go, reload.go.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SIGHUP-triggered config reload and the ConfigReloader async API.
|
||||
/// Covers:
|
||||
/// - PosixSignalRegistration for SIGHUP wired to ReloadConfig
|
||||
/// - ConfigReloader.ReloadAsync parses, diffs, and validates
|
||||
/// - ConfigReloader.ApplyDiff returns correct category flags
|
||||
/// - End-to-end reload via config file rewrite and ReloadConfigOrThrow
|
||||
/// Reference: Go server/reload.go — Reload, applyOptions.
|
||||
/// </summary>
|
||||
public class SignalReloadTests
|
||||
{
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<Socket> RawConnectAsync(int port)
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||||
var buf = new byte[4096];
|
||||
await sock.ReceiveAsync(buf, SocketFlags.None);
|
||||
return sock;
|
||||
}
|
||||
|
||||
private static void WriteConfigAndReload(NatsServer server, string configPath, string configText)
|
||||
{
|
||||
File.WriteAllText(configPath, configText);
|
||||
server.ReloadConfigOrThrow();
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that HandleSignals registers a SIGHUP handler that calls ReloadConfig.
|
||||
/// We cannot actually send SIGHUP in a test, but we verify the handler is registered
|
||||
/// by confirming ReloadConfig works when called directly, and that the server survives
|
||||
/// signal registration without error.
|
||||
/// Reference: Go server/signals_unix.go — handleSignals.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HandleSignals_registers_sighup_handler()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-sighup-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
File.WriteAllText(configPath, $"port: {port}\ndebug: false");
|
||||
|
||||
var options = new NatsOptions { ConfigFile = configPath, Port = port };
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Register signal handlers — should not throw
|
||||
server.HandleSignals();
|
||||
|
||||
// Verify the reload mechanism works by calling it directly
|
||||
// (simulating what SIGHUP would trigger)
|
||||
File.WriteAllText(configPath, $"port: {port}\ndebug: true");
|
||||
server.ReloadConfig();
|
||||
|
||||
// The server should still be operational
|
||||
await using var client = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{port}",
|
||||
});
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ConfigReloader.ReloadAsync correctly detects an unchanged config file.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReloadAsync_detects_unchanged_config()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-noop-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(configPath, "port: 4222\ndebug: false");
|
||||
|
||||
var currentOpts = new NatsOptions { ConfigFile = configPath, Port = 4222 };
|
||||
|
||||
// Compute initial digest
|
||||
var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath);
|
||||
|
||||
var result = await ConfigReloader.ReloadAsync(
|
||||
configPath, currentOpts, initialDigest, null, [], CancellationToken.None);
|
||||
|
||||
result.Unchanged.ShouldBeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ConfigReloader.ReloadAsync correctly detects config changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReloadAsync_detects_changes()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-change-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(configPath, "port: 4222\ndebug: false");
|
||||
|
||||
var currentOpts = new NatsOptions { ConfigFile = configPath, Port = 4222, Debug = false };
|
||||
|
||||
// Compute initial digest
|
||||
var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath);
|
||||
|
||||
// Change the config file
|
||||
File.WriteAllText(configPath, "port: 4222\ndebug: true");
|
||||
|
||||
var result = await ConfigReloader.ReloadAsync(
|
||||
configPath, currentOpts, initialDigest, null, [], CancellationToken.None);
|
||||
|
||||
result.Unchanged.ShouldBeFalse();
|
||||
result.NewOptions.ShouldNotBeNull();
|
||||
result.NewOptions!.Debug.ShouldBeTrue();
|
||||
result.Changes.ShouldNotBeNull();
|
||||
result.Changes!.Count.ShouldBeGreaterThan(0);
|
||||
result.HasErrors.ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ConfigReloader.ReloadAsync reports errors for non-reloadable changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReloadAsync_reports_non_reloadable_errors()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-nonreload-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(configPath, "port: 4222\nserver_name: original");
|
||||
|
||||
var currentOpts = new NatsOptions
|
||||
{
|
||||
ConfigFile = configPath,
|
||||
Port = 4222,
|
||||
ServerName = "original",
|
||||
};
|
||||
|
||||
var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath);
|
||||
|
||||
// Change a non-reloadable option
|
||||
File.WriteAllText(configPath, "port: 4222\nserver_name: changed");
|
||||
|
||||
var result = await ConfigReloader.ReloadAsync(
|
||||
configPath, currentOpts, initialDigest, null, [], CancellationToken.None);
|
||||
|
||||
result.Unchanged.ShouldBeFalse();
|
||||
result.HasErrors.ShouldBeTrue();
|
||||
result.Errors!.ShouldContain(e => e.Contains("ServerName"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ConfigReloader.ApplyDiff returns correct category flags.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyDiff_returns_correct_category_flags()
|
||||
{
|
||||
var oldOpts = new NatsOptions { Debug = false, Username = "old" };
|
||||
var newOpts = new NatsOptions { Debug = true, Username = "new" };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var result = ConfigReloader.ApplyDiff(changes, oldOpts, newOpts);
|
||||
|
||||
result.HasLoggingChanges.ShouldBeTrue();
|
||||
result.HasAuthChanges.ShouldBeTrue();
|
||||
result.ChangeCount.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ApplyDiff detects TLS changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyDiff_detects_tls_changes()
|
||||
{
|
||||
var oldOpts = new NatsOptions { TlsCert = null };
|
||||
var newOpts = new NatsOptions { TlsCert = "/path/to/cert.pem" };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var result = ConfigReloader.ApplyDiff(changes, oldOpts, newOpts);
|
||||
|
||||
result.HasTlsChanges.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ReloadAsync preserves CLI overrides during reload.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReloadAsync_preserves_cli_overrides()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-cli-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(configPath, "port: 4222\ndebug: false");
|
||||
|
||||
var currentOpts = new NatsOptions { ConfigFile = configPath, Port = 4222, Debug = true };
|
||||
var cliSnapshot = new NatsOptions { Debug = true };
|
||||
var cliFlags = new HashSet<string> { "Debug" };
|
||||
|
||||
var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath);
|
||||
|
||||
// Change config — debug goes to true in file, but CLI override also says true
|
||||
File.WriteAllText(configPath, "port: 4222\ndebug: true");
|
||||
|
||||
var result = await ConfigReloader.ReloadAsync(
|
||||
configPath, currentOpts, initialDigest, cliSnapshot, cliFlags, CancellationToken.None);
|
||||
|
||||
// Config changed, so it should not be "unchanged"
|
||||
result.Unchanged.ShouldBeFalse();
|
||||
result.NewOptions.ShouldNotBeNull();
|
||||
result.NewOptions!.Debug.ShouldBeTrue("CLI override should preserve debug=true");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies end-to-end: rewrite config file and call ReloadConfigOrThrow
|
||||
/// to apply max_connections changes, then verify new connections are rejected.
|
||||
/// Reference: Go server/reload_test.go — TestConfigReloadMaxConnections.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reload_via_config_file_rewrite_applies_changes()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-e2e-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
File.WriteAllText(configPath, $"port: {port}\nmax_connections: 65536");
|
||||
|
||||
var options = new NatsOptions { ConfigFile = configPath, Port = port };
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Establish one connection
|
||||
using var c1 = await RawConnectAsync(port);
|
||||
server.ClientCount.ShouldBe(1);
|
||||
|
||||
// Reduce max_connections to 1 via reload
|
||||
WriteConfigAndReload(server, configPath, $"port: {port}\nmax_connections: 1");
|
||||
|
||||
// New connection should be rejected
|
||||
using var c2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await c2.ConnectAsync(IPAddress.Loopback, port);
|
||||
var response = await SocketTestHelper.ReadUntilAsync(c2, "-ERR", timeoutMs: 5000);
|
||||
response.ShouldContain("maximum connections exceeded");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ReloadConfigOrThrow throws for non-reloadable changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReloadConfigOrThrow_throws_on_non_reloadable_change()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-throw-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
File.WriteAllText(configPath, $"port: {port}\nserver_name: original");
|
||||
|
||||
var options = new NatsOptions { ConfigFile = configPath, Port = port, ServerName = "original" };
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Try to change a non-reloadable option
|
||||
File.WriteAllText(configPath, $"port: {port}\nserver_name: changed");
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => server.ReloadConfigOrThrow())
|
||||
.Message.ShouldContain("ServerName");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ReloadConfig does not throw when no config file is specified
|
||||
/// (it logs a warning and returns).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReloadConfig_no_config_file_does_not_throw()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
// Should not throw; just logs a warning
|
||||
Should.NotThrow(() => server.ReloadConfig());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ReloadConfigOrThrow throws when no config file is specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReloadConfigOrThrow_throws_when_no_config_file()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => server.ReloadConfigOrThrow())
|
||||
.Message.ShouldContain("No config file");
|
||||
}
|
||||
}
|
||||
386
tests/NATS.Server.Core.Tests/Configuration/TlsReloadTests.cs
Normal file
386
tests/NATS.Server.Core.Tests/Configuration/TlsReloadTests.cs
Normal file
@@ -0,0 +1,386 @@
|
||||
// Tests for TLS certificate hot reload (E9).
|
||||
// Verifies that TlsCertificateProvider supports atomic cert swapping
|
||||
// and that ConfigReloader.ReloadTlsCertificate integrates correctly.
|
||||
// Reference: golang/nats-server/server/reload_test.go — TestConfigReloadRotateTLS (line 392).
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Tls;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Configuration;
|
||||
|
||||
public class TlsReloadTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a self-signed X509Certificate2 for testing.
|
||||
/// </summary>
|
||||
private static X509Certificate2 GenerateSelfSignedCert(string cn = "test")
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1));
|
||||
// Export and re-import to ensure the cert has the private key bound
|
||||
return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pkcs12), null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertificateProvider_GetCurrentCertificate_ReturnsInitialCert()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — initial cert is usable
|
||||
var cert = GenerateSelfSignedCert("initial");
|
||||
using var provider = new TlsCertificateProvider(cert);
|
||||
|
||||
var current = provider.GetCurrentCertificate();
|
||||
|
||||
current.ShouldNotBeNull();
|
||||
current.Subject.ShouldContain("initial");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertificateProvider_SwapCertificate_ReturnsOldCert()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — cert rotation returns old cert
|
||||
var cert1 = GenerateSelfSignedCert("cert1");
|
||||
var cert2 = GenerateSelfSignedCert("cert2");
|
||||
using var provider = new TlsCertificateProvider(cert1);
|
||||
|
||||
var old = provider.SwapCertificate(cert2);
|
||||
|
||||
old.ShouldNotBeNull();
|
||||
old.Subject.ShouldContain("cert1");
|
||||
old.Dispose();
|
||||
|
||||
var current = provider.GetCurrentCertificate();
|
||||
current.ShouldNotBeNull();
|
||||
current.Subject.ShouldContain("cert2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertificateProvider_SwapCertificate_IncrementsVersion()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — version tracking for reload detection
|
||||
var cert1 = GenerateSelfSignedCert("v1");
|
||||
var cert2 = GenerateSelfSignedCert("v2");
|
||||
using var provider = new TlsCertificateProvider(cert1);
|
||||
|
||||
var v0 = provider.Version;
|
||||
v0.ShouldBe(0);
|
||||
|
||||
provider.SwapCertificate(cert2)?.Dispose();
|
||||
provider.Version.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertificateProvider_MultipleSwa_NewConnectionsGetLatest()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — multiple rotations, each new
|
||||
// handshake gets the latest certificate
|
||||
var cert1 = GenerateSelfSignedCert("round1");
|
||||
var cert2 = GenerateSelfSignedCert("round2");
|
||||
var cert3 = GenerateSelfSignedCert("round3");
|
||||
using var provider = new TlsCertificateProvider(cert1);
|
||||
|
||||
provider.GetCurrentCertificate()!.Subject.ShouldContain("round1");
|
||||
|
||||
provider.SwapCertificate(cert2)?.Dispose();
|
||||
provider.GetCurrentCertificate()!.Subject.ShouldContain("round2");
|
||||
|
||||
provider.SwapCertificate(cert3)?.Dispose();
|
||||
provider.GetCurrentCertificate()!.Subject.ShouldContain("round3");
|
||||
|
||||
provider.Version.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CertificateProvider_ConcurrentAccess_IsThreadSafe()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — cert swap must be safe under
|
||||
// concurrent connection accept
|
||||
var cert1 = GenerateSelfSignedCert("concurrent1");
|
||||
using var provider = new TlsCertificateProvider(cert1);
|
||||
|
||||
var tasks = new Task[50];
|
||||
for (int i = 0; i < tasks.Length; i++)
|
||||
{
|
||||
var idx = i;
|
||||
tasks[i] = Task.Run(() =>
|
||||
{
|
||||
if (idx % 2 == 0)
|
||||
{
|
||||
// Readers — simulate new connections getting current cert
|
||||
var c = provider.GetCurrentCertificate();
|
||||
c.ShouldNotBeNull();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Writers — simulate reload
|
||||
var newCert = GenerateSelfSignedCert($"swap-{idx}");
|
||||
provider.SwapCertificate(newCert)?.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// After all swaps, the provider should still return a valid cert
|
||||
provider.GetCurrentCertificate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReloadTlsCertificate_NullProvider_ReturnsFalse()
|
||||
{
|
||||
// Edge case: server running without TLS
|
||||
var opts = new NatsOptions();
|
||||
var result = ConfigReloader.ReloadTlsCertificate(opts, null);
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReloadTlsCertificate_NoTlsConfig_ReturnsFalse()
|
||||
{
|
||||
// Edge case: provider exists but options don't have TLS paths
|
||||
var cert = GenerateSelfSignedCert("no-tls");
|
||||
using var provider = new TlsCertificateProvider(cert);
|
||||
|
||||
var opts = new NatsOptions(); // HasTls is false (no TlsCert/TlsKey)
|
||||
var result = ConfigReloader.ReloadTlsCertificate(opts, provider);
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReloadTlsCertificate_WithCertFiles_SwapsCertAndSslOptions()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — full reload with cert files.
|
||||
// Write a self-signed cert to temp files and verify the provider loads it.
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"nats-tls-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
var certPath = Path.Combine(tempDir, "cert.pem");
|
||||
var keyPath = Path.Combine(tempDir, "key.pem");
|
||||
WriteSelfSignedCertFiles(certPath, keyPath, "reload-test");
|
||||
|
||||
// Create provider with initial cert
|
||||
var initialCert = GenerateSelfSignedCert("initial");
|
||||
using var provider = new TlsCertificateProvider(initialCert);
|
||||
|
||||
var opts = new NatsOptions { TlsCert = certPath, TlsKey = keyPath };
|
||||
var result = ConfigReloader.ReloadTlsCertificate(opts, provider);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
provider.Version.ShouldBeGreaterThan(0);
|
||||
provider.GetCurrentCertificate().ShouldNotBeNull();
|
||||
provider.GetCurrentSslOptions().ShouldNotBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigDiff_DetectsTlsChanges()
|
||||
{
|
||||
// Go parity: TestConfigReloadEnableTLS, TestConfigReloadDisableTLS
|
||||
// Verify that diff detects TLS option changes and flags them
|
||||
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem", TlsKey = "/old/key.pem" };
|
||||
var newOpts = new NatsOptions { TlsCert = "/new/cert.pem", TlsKey = "/new/key.pem" };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
|
||||
changes.Count.ShouldBeGreaterThan(0);
|
||||
changes.ShouldContain(c => c.IsTlsChange && c.Name == "TlsCert");
|
||||
changes.ShouldContain(c => c.IsTlsChange && c.Name == "TlsKey");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigDiff_TlsVerifyChange_IsTlsChange()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — enabling client verification
|
||||
var oldOpts = new NatsOptions { TlsVerify = false };
|
||||
var newOpts = new NatsOptions { TlsVerify = true };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
|
||||
changes.ShouldContain(c => c.IsTlsChange && c.Name == "TlsVerify");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigApplyResult_ReportsTlsChanges()
|
||||
{
|
||||
// Verify ApplyDiff flags TLS changes correctly
|
||||
var changes = new List<IConfigChange>
|
||||
{
|
||||
new ConfigChange("TlsCert", isTlsChange: true),
|
||||
new ConfigChange("TlsKey", isTlsChange: true),
|
||||
};
|
||||
var oldOpts = new NatsOptions();
|
||||
var newOpts = new NatsOptions();
|
||||
|
||||
var result = ConfigReloader.ApplyDiff(changes, oldOpts, newOpts);
|
||||
|
||||
result.HasTlsChanges.ShouldBeTrue();
|
||||
result.ChangeCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
|
||||
// ─── ReloadTlsCertificates (TlsReloadResult) tests ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void No_cert_change_returns_no_change()
|
||||
{
|
||||
// Go parity: tlsConfigReload — identical cert path means no reload needed
|
||||
var oldOpts = new NatsOptions { TlsCert = "/same/cert.pem" };
|
||||
var newOpts = new NatsOptions { TlsCert = "/same/cert.pem" };
|
||||
|
||||
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
|
||||
|
||||
result.CertificateChanged.ShouldBeFalse();
|
||||
result.CertificateLoaded.ShouldBeFalse();
|
||||
result.Error.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cert_path_changed_detected()
|
||||
{
|
||||
// Go parity: tlsConfigReload — different cert path triggers reload
|
||||
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" };
|
||||
var newOpts = new NatsOptions { TlsCert = "/new/cert.pem" };
|
||||
|
||||
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
|
||||
|
||||
result.CertificateChanged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cert_path_set_returns_path()
|
||||
{
|
||||
// Verify CertificatePath reflects the new cert path when changed
|
||||
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" };
|
||||
var newOpts = new NatsOptions { TlsCert = "/new/cert.pem" };
|
||||
|
||||
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
|
||||
|
||||
result.CertificatePath.ShouldBe("/new/cert.pem");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_cert_file_returns_error()
|
||||
{
|
||||
// Go parity: tlsConfigReload — non-existent cert path returns an error
|
||||
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" };
|
||||
var newOpts = new NatsOptions { TlsCert = "/nonexistent/path/cert.pem" };
|
||||
|
||||
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
|
||||
|
||||
result.Error.ShouldNotBeNullOrWhiteSpace();
|
||||
result.Error!.ShouldContain("/nonexistent/path/cert.pem");
|
||||
result.CertificateLoaded.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_to_null_no_change()
|
||||
{
|
||||
// Both TlsCert null — no TLS configured on either side, no change
|
||||
var oldOpts = new NatsOptions { TlsCert = null };
|
||||
var newOpts = new NatsOptions { TlsCert = null };
|
||||
|
||||
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
|
||||
|
||||
result.CertificateChanged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_to_value_detected()
|
||||
{
|
||||
// Go parity: TestConfigReloadEnableTLS — enabling TLS is detected as a change
|
||||
var oldOpts = new NatsOptions { TlsCert = null };
|
||||
var newOpts = new NatsOptions { TlsCert = "/new/cert.pem" };
|
||||
|
||||
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
|
||||
|
||||
result.CertificateChanged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Value_to_null_detected()
|
||||
{
|
||||
// Go parity: TestConfigReloadDisableTLS — disabling TLS is a change, loads successfully
|
||||
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" };
|
||||
var newOpts = new NatsOptions { TlsCert = null };
|
||||
|
||||
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
|
||||
|
||||
result.CertificateChanged.ShouldBeTrue();
|
||||
result.CertificateLoaded.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Valid_cert_path_loaded_true()
|
||||
{
|
||||
// Go parity: tlsConfigReload — file exists, so CertificateLoaded is true
|
||||
var tempFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" };
|
||||
var newOpts = new NatsOptions { TlsCert = tempFile };
|
||||
|
||||
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
|
||||
|
||||
result.CertificateChanged.ShouldBeTrue();
|
||||
result.CertificateLoaded.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_null_on_success()
|
||||
{
|
||||
// Successful reload (file exists) must have Error as null
|
||||
var tempFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" };
|
||||
var newOpts = new NatsOptions { TlsCert = tempFile };
|
||||
|
||||
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
|
||||
|
||||
result.Error.ShouldBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_empty_strings_no_change()
|
||||
{
|
||||
// Both TlsCert are empty string — treat as equal, no change
|
||||
var oldOpts = new NatsOptions { TlsCert = string.Empty };
|
||||
var newOpts = new NatsOptions { TlsCert = string.Empty };
|
||||
|
||||
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
|
||||
|
||||
result.CertificateChanged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to write a self-signed certificate to PEM files.
|
||||
/// </summary>
|
||||
private static void WriteSelfSignedCertFiles(string certPath, string keyPath, string cn)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1));
|
||||
|
||||
File.WriteAllText(certPath, cert.ExportCertificatePem());
|
||||
File.WriteAllText(keyPath, rsa.ExportRSAPrivateKeyPem());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using NATS.Server.TestUtilities.Parity;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class DifferencesParityClosureTests
|
||||
{
|
||||
[Fact]
|
||||
public void Differences_md_has_no_remaining_baseline_n_or_stub_rows_in_tracked_scope()
|
||||
{
|
||||
var report = NATS.Server.TestUtilities.Parity.ParityRowInspector.Load("differences.md");
|
||||
report.UnresolvedRows.ShouldBeEmpty(string.Join(
|
||||
Environment.NewLine,
|
||||
report.UnresolvedRows.Select(r => $"{r.Section} :: {r.SubSection} :: {r.Feature} [{r.DotNetStatus}]")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jetstream_truth_matrix_has_no_row_level_drift()
|
||||
{
|
||||
var report = NATS.Server.TestUtilities.Parity.JetStreamParityTruthMatrix.Load(
|
||||
"differences.md",
|
||||
"docs/plans/2026-02-23-jetstream-remaining-parity-map.md");
|
||||
|
||||
report.DriftRows.ShouldBeEmpty(string.Join(
|
||||
Environment.NewLine,
|
||||
report.DriftRows.Select(r => $"{r.Feature} [{r.DifferencesStatus}|{r.EvidenceStatus}] :: {r.Reason}")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Differences_and_strict_capability_maps_have_no_claims_without_behavior_and_test_evidence()
|
||||
{
|
||||
var inventory = NATS.Server.TestUtilities.Parity.NatsCapabilityInventory.Load("docs/plans/2026-02-23-nats-strict-full-go-parity-map.md");
|
||||
var incomplete = inventory.Rows
|
||||
.Where(r => !string.Equals(r.Behavior, "done", StringComparison.OrdinalIgnoreCase)
|
||||
|| !string.Equals(r.Tests, "done", StringComparison.OrdinalIgnoreCase)
|
||||
|| !string.Equals(r.Docs, "closed", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
incomplete.ShouldBeEmpty(string.Join(
|
||||
Environment.NewLine,
|
||||
incomplete.Select(r => $"{r.Capability} [{r.Behavior}|{r.Tests}|{r.Docs}]")));
|
||||
}
|
||||
}
|
||||
50
tests/NATS.Server.Core.Tests/FlushCoalescingTests.cs
Normal file
50
tests/NATS.Server.Core.Tests/FlushCoalescingTests.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
// Go reference: server/client.go (maxFlushPending, pcd, flush signal coalescing)
|
||||
|
||||
public class FlushCoalescingTests
|
||||
{
|
||||
[Fact]
|
||||
public void MaxFlushPending_defaults_to_10()
|
||||
{
|
||||
// Go reference: server/client.go maxFlushPending constant
|
||||
NatsClient.MaxFlushPending.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldCoalesceFlush_true_when_below_max()
|
||||
{
|
||||
// When flush signals pending is below MaxFlushPending, coalescing is allowed
|
||||
// Go reference: server/client.go fsp < maxFlushPending check
|
||||
var pending = 5;
|
||||
var shouldCoalesce = pending < NatsClient.MaxFlushPending;
|
||||
shouldCoalesce.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldCoalesceFlush_false_when_at_max()
|
||||
{
|
||||
// When flush signals pending reaches MaxFlushPending, force flush
|
||||
var pending = NatsClient.MaxFlushPending;
|
||||
var shouldCoalesce = pending < NatsClient.MaxFlushPending;
|
||||
shouldCoalesce.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldCoalesceFlush_false_when_above_max()
|
||||
{
|
||||
// Above max, definitely don't coalesce
|
||||
var pending = NatsClient.MaxFlushPending + 5;
|
||||
var shouldCoalesce = pending < NatsClient.MaxFlushPending;
|
||||
shouldCoalesce.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlushCoalescing_constant_matches_go_reference()
|
||||
{
|
||||
// Go reference: server/client.go maxFlushPending = 10
|
||||
// Verify the constant is accessible and correct
|
||||
NatsClient.MaxFlushPending.ShouldBeGreaterThan(0);
|
||||
NatsClient.MaxFlushPending.ShouldBeLessThanOrEqualTo(100);
|
||||
}
|
||||
}
|
||||
21
tests/NATS.Server.Core.Tests/GoParityRunnerTests.cs
Normal file
21
tests/NATS.Server.Core.Tests/GoParityRunnerTests.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class GoParityRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Go_parity_runner_builds_expected_suite_filter()
|
||||
{
|
||||
var cmd = GoParityRunner.BuildCommand();
|
||||
cmd.ShouldContain("go test");
|
||||
cmd.ShouldContain("TestJetStream");
|
||||
cmd.ShouldContain("TestRaft");
|
||||
}
|
||||
}
|
||||
|
||||
internal static class GoParityRunner
|
||||
{
|
||||
public static string BuildCommand()
|
||||
{
|
||||
return "go test -v -run 'TestJetStream|TestJetStreamCluster|TestLongCluster|TestRaft' ./server -count=1 -timeout=180m";
|
||||
}
|
||||
}
|
||||
1089
tests/NATS.Server.Core.Tests/InfrastructureGoParityTests.cs
Normal file
1089
tests/NATS.Server.Core.Tests/InfrastructureGoParityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
134
tests/NATS.Server.Core.Tests/IntegrationTests.cs
Normal file
134
tests/NATS.Server.Core.Tests/IntegrationTests.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class IntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task _serverTask = null!;
|
||||
|
||||
public IntegrationTests()
|
||||
{
|
||||
_port = TestPortAllocator.GetFreePort();
|
||||
_server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_serverTask = _server.StartAsync(_cts.Token);
|
||||
await _server.WaitForReadyAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
_server.Dispose();
|
||||
}
|
||||
|
||||
|
||||
private NatsConnection CreateClient()
|
||||
{
|
||||
var opts = new NatsOpts { Url = $"nats://127.0.0.1:{_port}" };
|
||||
return new NatsConnection(opts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PubSub_basic()
|
||||
{
|
||||
await using var pub = CreateClient();
|
||||
await using var sub = CreateClient();
|
||||
|
||||
await pub.ConnectAsync();
|
||||
await sub.ConnectAsync();
|
||||
|
||||
await using var subscription = await sub.SubscribeCoreAsync<string>("test.subject");
|
||||
await sub.PingAsync();
|
||||
|
||||
await pub.PublishAsync("test.subject", "Hello NATS!");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await subscription.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldBe("Hello NATS!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PubSub_wildcard_star()
|
||||
{
|
||||
await using var pub = CreateClient();
|
||||
await using var sub = CreateClient();
|
||||
|
||||
await pub.ConnectAsync();
|
||||
await sub.ConnectAsync();
|
||||
|
||||
await using var subscription = await sub.SubscribeCoreAsync<string>("test.*");
|
||||
await sub.PingAsync();
|
||||
|
||||
await pub.PublishAsync("test.hello", "data");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await subscription.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Subject.ShouldBe("test.hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PubSub_wildcard_gt()
|
||||
{
|
||||
await using var pub = CreateClient();
|
||||
await using var sub = CreateClient();
|
||||
|
||||
await pub.ConnectAsync();
|
||||
await sub.ConnectAsync();
|
||||
|
||||
await using var subscription = await sub.SubscribeCoreAsync<string>("test.>");
|
||||
await sub.PingAsync();
|
||||
|
||||
await pub.PublishAsync("test.foo.bar.baz", "data");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await subscription.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Subject.ShouldBe("test.foo.bar.baz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PubSub_fan_out()
|
||||
{
|
||||
await using var pub = CreateClient();
|
||||
await using var sub1 = CreateClient();
|
||||
await using var sub2 = CreateClient();
|
||||
|
||||
await pub.ConnectAsync();
|
||||
await sub1.ConnectAsync();
|
||||
await sub2.ConnectAsync();
|
||||
|
||||
await using var s1 = await sub1.SubscribeCoreAsync<string>("fanout");
|
||||
await using var s2 = await sub2.SubscribeCoreAsync<string>("fanout");
|
||||
await sub1.PingAsync();
|
||||
await sub2.PingAsync();
|
||||
|
||||
await pub.PublishAsync("fanout", "hello");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg1 = await s1.Msgs.ReadAsync(timeout.Token);
|
||||
var msg2 = await s2.Msgs.ReadAsync(timeout.Token);
|
||||
msg1.Data.ShouldBe("hello");
|
||||
msg2.Data.ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PingPong()
|
||||
{
|
||||
await using var client = CreateClient();
|
||||
await client.ConnectAsync();
|
||||
|
||||
// If we got here, the connection is alive and PING/PONG works
|
||||
await client.PingAsync();
|
||||
}
|
||||
}
|
||||
0
tests/NATS.Server.Core.Tests/Internal/Avl/.gitkeep
Normal file
0
tests/NATS.Server.Core.Tests/Internal/Avl/.gitkeep
Normal file
540
tests/NATS.Server.Core.Tests/Internal/Avl/SequenceSetTests.cs
Normal file
540
tests/NATS.Server.Core.Tests/Internal/Avl/SequenceSetTests.cs
Normal file
@@ -0,0 +1,540 @@
|
||||
// Copyright 2024 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System.Diagnostics;
|
||||
using NATS.Server.Internal.Avl;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Internal.Avl;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the AVL-backed SequenceSet, ported from Go server/avl/seqset_test.go
|
||||
/// and server/avl/norace_test.go.
|
||||
/// </summary>
|
||||
public class SequenceSetTests
|
||||
{
|
||||
private const int NumEntries = SequenceSet.NumEntries; // 2048
|
||||
private const int BitsPerBucket = SequenceSet.BitsPerBucket;
|
||||
private const int NumBuckets = SequenceSet.NumBuckets;
|
||||
|
||||
// Go: TestSeqSetBasics server/avl/seqset_test.go:22
|
||||
[Fact]
|
||||
public void Basics_InsertExistsDelete()
|
||||
{
|
||||
var ss = new SequenceSet();
|
||||
|
||||
ulong[] seqs = [22, 222, 2000, 2, 2, 4];
|
||||
foreach (var seq in seqs)
|
||||
{
|
||||
ss.Insert(seq);
|
||||
ss.Exists(seq).ShouldBeTrue();
|
||||
}
|
||||
|
||||
ss.Nodes.ShouldBe(1);
|
||||
ss.Size.ShouldBe(seqs.Length - 1); // One dup (2 appears twice)
|
||||
var (lh, rh) = ss.Heights();
|
||||
lh.ShouldBe(0);
|
||||
rh.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestSeqSetLeftLean server/avl/seqset_test.go:38
|
||||
[Fact]
|
||||
public void LeftLean_TreeBalancesCorrectly()
|
||||
{
|
||||
var ss = new SequenceSet();
|
||||
|
||||
// Insert from high to low to create a left-leaning tree.
|
||||
for (var i = (ulong)(4 * NumEntries); i > 0; i--)
|
||||
{
|
||||
ss.Insert(i);
|
||||
}
|
||||
|
||||
ss.Nodes.ShouldBe(5);
|
||||
ss.Size.ShouldBe(4 * NumEntries);
|
||||
var (lh, rh) = ss.Heights();
|
||||
lh.ShouldBe(2);
|
||||
rh.ShouldBe(1);
|
||||
}
|
||||
|
||||
// Go: TestSeqSetRightLean server/avl/seqset_test.go:52
|
||||
[Fact]
|
||||
public void RightLean_TreeBalancesCorrectly()
|
||||
{
|
||||
var ss = new SequenceSet();
|
||||
|
||||
// Insert from low to high to create a right-leaning tree.
|
||||
for (var i = 0UL; i < (ulong)(4 * NumEntries); i++)
|
||||
{
|
||||
ss.Insert(i);
|
||||
}
|
||||
|
||||
ss.Nodes.ShouldBe(4);
|
||||
ss.Size.ShouldBe(4 * NumEntries);
|
||||
var (lh, rh) = ss.Heights();
|
||||
lh.ShouldBe(1);
|
||||
rh.ShouldBe(2);
|
||||
}
|
||||
|
||||
// Go: TestSeqSetCorrectness server/avl/seqset_test.go:66
|
||||
[Fact]
|
||||
public void Correctness_RandomInsertDelete()
|
||||
{
|
||||
// Generate 100k sequences across 500k range.
|
||||
const int num = 100_000;
|
||||
const int max = 500_000;
|
||||
|
||||
var rng = new Random(42);
|
||||
var set = new HashSet<ulong>();
|
||||
var ss = new SequenceSet();
|
||||
|
||||
for (var i = 0; i < num; i++)
|
||||
{
|
||||
var n = (ulong)rng.NextInt64(max + 1);
|
||||
ss.Insert(n);
|
||||
set.Add(n);
|
||||
}
|
||||
|
||||
for (var i = 0UL; i <= max; i++)
|
||||
{
|
||||
ss.Exists(i).ShouldBe(set.Contains(i));
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestSeqSetRange server/avl/seqset_test.go:85
|
||||
[Fact]
|
||||
public void Range_IteratesInOrder()
|
||||
{
|
||||
var num = 2 * NumEntries + 22;
|
||||
var nums = new List<ulong>(num);
|
||||
for (var i = 0; i < num; i++)
|
||||
{
|
||||
nums.Add((ulong)i);
|
||||
}
|
||||
|
||||
// Shuffle and insert.
|
||||
var rng = new Random(42);
|
||||
Shuffle(nums, rng);
|
||||
|
||||
var ss = new SequenceSet();
|
||||
foreach (var n in nums)
|
||||
{
|
||||
ss.Insert(n);
|
||||
}
|
||||
|
||||
// Range should produce ascending order.
|
||||
var result = new List<ulong>();
|
||||
ss.Range(n =>
|
||||
{
|
||||
result.Add(n);
|
||||
return true;
|
||||
});
|
||||
|
||||
result.Count.ShouldBe(num);
|
||||
for (var i = 0UL; i < (ulong)num; i++)
|
||||
{
|
||||
result[(int)i].ShouldBe(i);
|
||||
}
|
||||
|
||||
// Test truncating the range call.
|
||||
result.Clear();
|
||||
ss.Range(n =>
|
||||
{
|
||||
if (n >= 10)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result.Add(n);
|
||||
return true;
|
||||
});
|
||||
|
||||
result.Count.ShouldBe(10);
|
||||
for (var i = 0UL; i < 10; i++)
|
||||
{
|
||||
result[(int)i].ShouldBe(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestSeqSetDelete server/avl/seqset_test.go:123
|
||||
[Fact]
|
||||
public void Delete_VariousPatterns()
|
||||
{
|
||||
var ss = new SequenceSet();
|
||||
|
||||
ulong[] seqs = [22, 222, 2222, 2, 2, 4];
|
||||
foreach (var seq in seqs)
|
||||
{
|
||||
ss.Insert(seq);
|
||||
}
|
||||
|
||||
foreach (var seq in seqs)
|
||||
{
|
||||
ss.Delete(seq);
|
||||
ss.Exists(seq).ShouldBeFalse();
|
||||
}
|
||||
|
||||
ss.Root.ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: TestSeqSetInsertAndDeletePedantic server/avl/seqset_test.go:139
|
||||
[Fact]
|
||||
public void InsertAndDelete_PedanticVerification()
|
||||
{
|
||||
var ss = new SequenceSet();
|
||||
|
||||
var num = 50 * NumEntries + 22;
|
||||
var nums = new List<ulong>(num);
|
||||
for (var i = 0; i < num; i++)
|
||||
{
|
||||
nums.Add((ulong)i);
|
||||
}
|
||||
|
||||
var rng = new Random(42);
|
||||
Shuffle(nums, rng);
|
||||
|
||||
// Insert all, verify balanced after each insert.
|
||||
foreach (var n in nums)
|
||||
{
|
||||
ss.Insert(n);
|
||||
VerifyBalanced(ss);
|
||||
}
|
||||
|
||||
ss.Root.ShouldNotBeNull();
|
||||
|
||||
// Delete all, verify balanced after each delete.
|
||||
foreach (var n in nums)
|
||||
{
|
||||
ss.Delete(n);
|
||||
VerifyBalanced(ss);
|
||||
ss.Exists(n).ShouldBeFalse();
|
||||
if (ss.Size > 0)
|
||||
{
|
||||
ss.Root.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
ss.Root.ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: TestSeqSetMinMax server/avl/seqset_test.go:181
|
||||
[Fact]
|
||||
public void MinMax_TracksCorrectly()
|
||||
{
|
||||
var ss = new SequenceSet();
|
||||
|
||||
// Simple single node.
|
||||
ulong[] seqs = [22, 222, 2222, 2, 2, 4];
|
||||
foreach (var seq in seqs)
|
||||
{
|
||||
ss.Insert(seq);
|
||||
}
|
||||
|
||||
var (min, max) = ss.MinMax();
|
||||
min.ShouldBe(2UL);
|
||||
max.ShouldBe(2222UL);
|
||||
|
||||
// Multi-node
|
||||
ss.Empty();
|
||||
|
||||
var num = 22 * NumEntries + 22;
|
||||
var nums = new List<ulong>(num);
|
||||
for (var i = 0; i < num; i++)
|
||||
{
|
||||
nums.Add((ulong)i);
|
||||
}
|
||||
|
||||
var rng = new Random(42);
|
||||
Shuffle(nums, rng);
|
||||
foreach (var n in nums)
|
||||
{
|
||||
ss.Insert(n);
|
||||
}
|
||||
|
||||
(min, max) = ss.MinMax();
|
||||
min.ShouldBe(0UL);
|
||||
max.ShouldBe((ulong)(num - 1));
|
||||
}
|
||||
|
||||
// Go: TestSeqSetClone server/avl/seqset_test.go:210
|
||||
[Fact]
|
||||
public void Clone_IndependentCopy()
|
||||
{
|
||||
// Generate 100k sequences across 500k range.
|
||||
const int num = 100_000;
|
||||
const int max = 500_000;
|
||||
|
||||
var rng = new Random(42);
|
||||
var ss = new SequenceSet();
|
||||
for (var i = 0; i < num; i++)
|
||||
{
|
||||
ss.Insert((ulong)rng.NextInt64(max + 1));
|
||||
}
|
||||
|
||||
var ssc = ss.Clone();
|
||||
ssc.Size.ShouldBe(ss.Size);
|
||||
ssc.Nodes.ShouldBe(ss.Nodes);
|
||||
}
|
||||
|
||||
// Go: TestSeqSetUnion server/avl/seqset_test.go:225
|
||||
[Fact]
|
||||
public void Union_MergesSets()
|
||||
{
|
||||
var ss1 = new SequenceSet();
|
||||
var ss2 = new SequenceSet();
|
||||
|
||||
ulong[] seqs1 = [22, 222, 2222, 2, 2, 4];
|
||||
foreach (var seq in seqs1)
|
||||
{
|
||||
ss1.Insert(seq);
|
||||
}
|
||||
|
||||
ulong[] seqs2 = [33, 333, 3333, 3, 33_333, 333_333];
|
||||
foreach (var seq in seqs2)
|
||||
{
|
||||
ss2.Insert(seq);
|
||||
}
|
||||
|
||||
var ss = SequenceSet.CreateUnion(ss1, ss2);
|
||||
ss.Size.ShouldBe(11);
|
||||
|
||||
ulong[] allSeqs = [.. seqs1, .. seqs2];
|
||||
foreach (var n in allSeqs)
|
||||
{
|
||||
ss.Exists(n).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestSeqSetFirst server/avl/seqset_test.go:247
|
||||
[Fact]
|
||||
public void First_ReturnsMinimum()
|
||||
{
|
||||
var ss = new SequenceSet();
|
||||
|
||||
ulong[] seqs = [22, 222, 2222, 222_222];
|
||||
foreach (var seq in seqs)
|
||||
{
|
||||
// Normal case where we pick first/base.
|
||||
ss.Insert(seq);
|
||||
ss.Root!.Base.ShouldBe((seq / (ulong)NumEntries) * (ulong)NumEntries);
|
||||
ss.Empty();
|
||||
|
||||
// Where we set the minimum start value.
|
||||
ss.SetInitialMin(seq);
|
||||
ss.Insert(seq);
|
||||
ss.Root!.Base.ShouldBe(seq);
|
||||
ss.Empty();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestSeqSetDistinctUnion server/avl/seqset_test.go:265
|
||||
[Fact]
|
||||
public void DistinctUnion_NoOverlap()
|
||||
{
|
||||
var ss1 = new SequenceSet();
|
||||
ulong[] seqs1 = [1, 10, 100, 200];
|
||||
foreach (var seq in seqs1)
|
||||
{
|
||||
ss1.Insert(seq);
|
||||
}
|
||||
|
||||
var ss2 = new SequenceSet();
|
||||
ulong[] seqs2 = [5000, 6100, 6200, 6222];
|
||||
foreach (var seq in seqs2)
|
||||
{
|
||||
ss2.Insert(seq);
|
||||
}
|
||||
|
||||
var ss = ss1.Clone();
|
||||
ulong[] allSeqs = [.. seqs1, .. seqs2];
|
||||
|
||||
ss.Union(ss2);
|
||||
ss.Size.ShouldBe(allSeqs.Length);
|
||||
foreach (var seq in allSeqs)
|
||||
{
|
||||
ss.Exists(seq).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestSeqSetDecodeV1 server/avl/seqset_test.go:289
|
||||
[Fact]
|
||||
public void DecodeV1_BackwardsCompatible()
|
||||
{
|
||||
// Encoding from v1 which was 64 buckets.
|
||||
ulong[] seqs = [22, 222, 2222, 222_222, 2_222_222];
|
||||
var encStr =
|
||||
"FgEDAAAABQAAAABgAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAADgIQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAA==";
|
||||
|
||||
var enc = Convert.FromBase64String(encStr);
|
||||
var (ss, _) = SequenceSet.Decode(enc);
|
||||
|
||||
ss.Size.ShouldBe(seqs.Length);
|
||||
foreach (var seq in seqs)
|
||||
{
|
||||
ss.Exists(seq).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestNoRaceSeqSetSizeComparison server/avl/norace_test.go:33
|
||||
[Fact]
|
||||
public void SizeComparison_LargeSet()
|
||||
{
|
||||
// Create 5M random entries out of 7M range.
|
||||
const int num = 5_000_000;
|
||||
const int max = 7_000_000;
|
||||
|
||||
var rng = new Random(42);
|
||||
var seqs = new ulong[num];
|
||||
for (var i = 0; i < num; i++)
|
||||
{
|
||||
seqs[i] = (ulong)rng.NextInt64(max + 1);
|
||||
}
|
||||
|
||||
// Insert into a dictionary to compare.
|
||||
var dmap = new HashSet<ulong>(num);
|
||||
foreach (var n in seqs)
|
||||
{
|
||||
dmap.Add(n);
|
||||
}
|
||||
|
||||
// Insert into SequenceSet.
|
||||
var ss = new SequenceSet();
|
||||
foreach (var n in seqs)
|
||||
{
|
||||
ss.Insert(n);
|
||||
}
|
||||
|
||||
// Verify sizes match.
|
||||
ss.Size.ShouldBe(dmap.Count);
|
||||
|
||||
// Verify SequenceSet uses very few nodes relative to its element count.
|
||||
// With 2048 entries per node and 7M range, we expect ~ceil(7M/2048) = ~3419 nodes at most.
|
||||
ss.Nodes.ShouldBeLessThan(5000);
|
||||
}
|
||||
|
||||
// Go: TestNoRaceSeqSetEncodeLarge server/avl/norace_test.go:81
|
||||
[Fact]
|
||||
public void EncodeLarge_RoundTrips()
|
||||
{
|
||||
const int num = 2_500_000;
|
||||
const int max = 5_000_000;
|
||||
|
||||
var rng = new Random(42);
|
||||
var ss = new SequenceSet();
|
||||
for (var i = 0; i < num; i++)
|
||||
{
|
||||
ss.Insert((ulong)rng.NextInt64(max + 1));
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var buf = ss.Encode();
|
||||
sw.Stop();
|
||||
|
||||
// Encode should be fast (the Go test uses 1ms, we allow more for .NET JIT).
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
|
||||
|
||||
sw.Restart();
|
||||
var (ss2, bytesRead) = SequenceSet.Decode(buf);
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
|
||||
bytesRead.ShouldBe(buf.Length);
|
||||
ss2.Nodes.ShouldBe(ss.Nodes);
|
||||
ss2.Size.ShouldBe(ss.Size);
|
||||
}
|
||||
|
||||
// Go: TestNoRaceSeqSetRelativeSpeed server/avl/norace_test.go:123
|
||||
[Fact]
|
||||
public void RelativeSpeed_Performance()
|
||||
{
|
||||
const int num = 1_000_000;
|
||||
const int max = 3_000_000;
|
||||
|
||||
var rng = new Random(42);
|
||||
var seqs = new ulong[num];
|
||||
for (var i = 0; i < num; i++)
|
||||
{
|
||||
seqs[i] = (ulong)rng.NextInt64(max + 1);
|
||||
}
|
||||
|
||||
// SequenceSet insert.
|
||||
var sw = Stopwatch.StartNew();
|
||||
var ss = new SequenceSet();
|
||||
foreach (var n in seqs)
|
||||
{
|
||||
ss.Insert(n);
|
||||
}
|
||||
|
||||
var ssInsert = sw.Elapsed;
|
||||
|
||||
// SequenceSet lookup.
|
||||
sw.Restart();
|
||||
foreach (var n in seqs)
|
||||
{
|
||||
ss.Exists(n).ShouldBeTrue();
|
||||
}
|
||||
|
||||
var ssLookup = sw.Elapsed;
|
||||
|
||||
// Dictionary insert.
|
||||
sw.Restart();
|
||||
var dmap = new HashSet<ulong>();
|
||||
foreach (var n in seqs)
|
||||
{
|
||||
dmap.Add(n);
|
||||
}
|
||||
|
||||
var mapInsert = sw.Elapsed;
|
||||
|
||||
// Dictionary lookup.
|
||||
sw.Restart();
|
||||
foreach (var n in seqs)
|
||||
{
|
||||
dmap.Contains(n).ShouldBeTrue();
|
||||
}
|
||||
|
||||
var mapLookup = sw.Elapsed;
|
||||
|
||||
// Relaxed bounds: SequenceSet insert should be no more than 10x slower.
|
||||
// (.NET JIT and test host overhead can be significant vs Go's simpler runtime.)
|
||||
ssInsert.ShouldBeLessThan(mapInsert * 10);
|
||||
ssLookup.ShouldBeLessThan(mapLookup * 10);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the AVL tree is balanced at every node.</summary>
|
||||
private static void VerifyBalanced(SequenceSet ss)
|
||||
{
|
||||
if (ss.Root == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check all node heights and balance factors.
|
||||
SequenceSet.Node.NodeIter(ss.Root, n =>
|
||||
{
|
||||
var expectedHeight = SequenceSet.Node.MaxHeight(n) + 1;
|
||||
n.Height.ShouldBe(expectedHeight, $"Node height is wrong for node with base {n.Base}");
|
||||
});
|
||||
|
||||
var bf = SequenceSet.Node.BalanceFactor(ss.Root);
|
||||
bf.ShouldBeInRange(-1, 1, "Tree is unbalanced at root");
|
||||
}
|
||||
|
||||
/// <summary>Fisher-Yates shuffle.</summary>
|
||||
private static void Shuffle(List<ulong> list, Random rng)
|
||||
{
|
||||
for (var i = list.Count - 1; i > 0; i--)
|
||||
{
|
||||
var j = rng.Next(i + 1);
|
||||
(list[i], list[j]) = (list[j], list[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
0
tests/NATS.Server.Core.Tests/Internal/Gsl/.gitkeep
Normal file
0
tests/NATS.Server.Core.Tests/Internal/Gsl/.gitkeep
Normal file
@@ -0,0 +1,429 @@
|
||||
// Go reference: server/gsl/gsl_test.go
|
||||
// Tests for GenericSubjectList<T> trie-based subject matching.
|
||||
|
||||
using NATS.Server.Internal.Gsl;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Internal.Gsl;
|
||||
|
||||
public class GenericSubjectListTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper: count matches for a subject.
|
||||
/// </summary>
|
||||
private static int CountMatches<T>(GenericSubjectList<T> s, string subject) where T : IEquatable<T>
|
||||
{
|
||||
var count = 0;
|
||||
s.Match(subject, _ => count++);
|
||||
return count;
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistInit server/gsl/gsl_test.go:23
|
||||
[Fact]
|
||||
public void Init_EmptyList()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.Count.ShouldBe(0u);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistInsertCount server/gsl/gsl_test.go:29
|
||||
[Fact]
|
||||
public void InsertCount_TracksCorrectly()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.Insert("foo", 1);
|
||||
s.Insert("bar", 2);
|
||||
s.Insert("foo.bar", 3);
|
||||
s.Count.ShouldBe(3u);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistSimple server/gsl/gsl_test.go:37
|
||||
[Fact]
|
||||
public void Simple_ExactMatch()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.Insert("foo", 1);
|
||||
CountMatches(s, "foo").ShouldBe(1);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistSimpleMultiTokens server/gsl/gsl_test.go:43
|
||||
[Fact]
|
||||
public void SimpleMultiTokens_Match()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.Insert("foo.bar.baz", 1);
|
||||
CountMatches(s, "foo.bar.baz").ShouldBe(1);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistPartialWildcard server/gsl/gsl_test.go:49
|
||||
[Fact]
|
||||
public void PartialWildcard_StarMatches()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.Insert("a.b.c", 1);
|
||||
s.Insert("a.*.c", 2);
|
||||
CountMatches(s, "a.b.c").ShouldBe(2);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistPartialWildcardAtEnd server/gsl/gsl_test.go:56
|
||||
[Fact]
|
||||
public void PartialWildcardAtEnd_StarMatches()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.Insert("a.b.c", 1);
|
||||
s.Insert("a.b.*", 2);
|
||||
CountMatches(s, "a.b.c").ShouldBe(2);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistFullWildcard server/gsl/gsl_test.go:63
|
||||
[Fact]
|
||||
public void FullWildcard_GreaterThanMatches()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.Insert("a.b.c", 1);
|
||||
s.Insert("a.>", 2);
|
||||
CountMatches(s, "a.b.c").ShouldBe(2);
|
||||
CountMatches(s, "a.>").ShouldBe(1);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistRemove server/gsl/gsl_test.go:71
|
||||
[Fact]
|
||||
public void Remove_DecreasesCount()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
|
||||
s.Insert("a.b.c.d", 1);
|
||||
s.Count.ShouldBe(1u);
|
||||
CountMatches(s, "a.b.c.d").ShouldBe(1);
|
||||
|
||||
s.Remove("a.b.c.d", 1);
|
||||
s.Count.ShouldBe(0u);
|
||||
CountMatches(s, "a.b.c.d").ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistRemoveWildcard server/gsl/gsl_test.go:83
|
||||
[Fact]
|
||||
public void RemoveWildcard_CleansUp()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
|
||||
s.Insert("a.b.c.d", 11);
|
||||
s.Insert("a.b.*.d", 22);
|
||||
s.Insert("a.b.>", 33);
|
||||
s.Count.ShouldBe(3u);
|
||||
CountMatches(s, "a.b.c.d").ShouldBe(3);
|
||||
|
||||
s.Remove("a.b.*.d", 22);
|
||||
s.Count.ShouldBe(2u);
|
||||
CountMatches(s, "a.b.c.d").ShouldBe(2);
|
||||
|
||||
s.Remove("a.b.>", 33);
|
||||
s.Count.ShouldBe(1u);
|
||||
CountMatches(s, "a.b.c.d").ShouldBe(1);
|
||||
|
||||
s.Remove("a.b.c.d", 11);
|
||||
s.Count.ShouldBe(0u);
|
||||
CountMatches(s, "a.b.c.d").ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistRemoveCleanup server/gsl/gsl_test.go:105
|
||||
[Fact]
|
||||
public void RemoveCleanup_PrunesEmptyNodes()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.NumLevels().ShouldBe(0);
|
||||
s.Insert("a.b.c.d.e.f", 1);
|
||||
s.NumLevels().ShouldBe(6);
|
||||
s.Remove("a.b.c.d.e.f", 1);
|
||||
s.NumLevels().ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistRemoveCleanupWildcards server/gsl/gsl_test.go:114
|
||||
[Fact]
|
||||
public void RemoveCleanupWildcards_PrunesEmptyNodes()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.NumLevels().ShouldBe(0);
|
||||
s.Insert("a.b.*.d.e.>", 1);
|
||||
s.NumLevels().ShouldBe(6);
|
||||
s.Remove("a.b.*.d.e.>", 1);
|
||||
s.NumLevels().ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistInvalidSubjectsInsert server/gsl/gsl_test.go:123
|
||||
[Fact]
|
||||
public void InvalidSubjectsInsert_RejectsInvalid()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
|
||||
// Empty tokens and FWC not terminal
|
||||
Should.Throw<InvalidOperationException>(() => s.Insert(".foo", 1));
|
||||
Should.Throw<InvalidOperationException>(() => s.Insert("foo.", 1));
|
||||
Should.Throw<InvalidOperationException>(() => s.Insert("foo..bar", 1));
|
||||
Should.Throw<InvalidOperationException>(() => s.Insert("foo.bar..baz", 1));
|
||||
Should.Throw<InvalidOperationException>(() => s.Insert("foo.>.baz", 1));
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistBadSubjectOnRemove server/gsl/gsl_test.go:134
|
||||
[Fact]
|
||||
public void BadSubjectOnRemove_RejectsInvalid()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
Should.Throw<InvalidOperationException>(() => s.Insert("a.b..d", 1));
|
||||
Should.Throw<InvalidOperationException>(() => s.Remove("a.b..d", 1));
|
||||
Should.Throw<InvalidOperationException>(() => s.Remove("a.>.b", 1));
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistTwoTokenPubMatchSingleTokenSub server/gsl/gsl_test.go:141
|
||||
[Fact]
|
||||
public void TwoTokenPub_DoesNotMatchSingleTokenSub()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.Insert("foo", 1);
|
||||
CountMatches(s, "foo").ShouldBe(1);
|
||||
CountMatches(s, "foo.bar").ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistInsertWithWildcardsAsLiterals server/gsl/gsl_test.go:148
|
||||
[Fact]
|
||||
public void InsertWithWildcardsAsLiterals_TreatsAsLiteral()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
var subjects = new[] { "foo.*-", "foo.>-" };
|
||||
for (var i = 0; i < subjects.Length; i++)
|
||||
{
|
||||
s.Insert(subjects[i], i);
|
||||
CountMatches(s, "foo.bar").ShouldBe(0);
|
||||
CountMatches(s, subjects[i]).ShouldBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistRemoveWithWildcardsAsLiterals server/gsl/gsl_test.go:157
|
||||
[Fact]
|
||||
public void RemoveWithWildcardsAsLiterals_RemovesCorrectly()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
var subjects = new[] { "foo.*-", "foo.>-" };
|
||||
for (var i = 0; i < subjects.Length; i++)
|
||||
{
|
||||
s.Insert(subjects[i], i);
|
||||
CountMatches(s, "foo.bar").ShouldBe(0);
|
||||
CountMatches(s, subjects[i]).ShouldBe(1);
|
||||
Should.Throw<KeyNotFoundException>(() => s.Remove("foo.bar", i));
|
||||
s.Count.ShouldBe(1u);
|
||||
s.Remove(subjects[i], i);
|
||||
s.Count.ShouldBe(0u);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistMatchWithEmptyTokens server/gsl/gsl_test.go:170
|
||||
[Theory]
|
||||
[InlineData(".foo")]
|
||||
[InlineData("..foo")]
|
||||
[InlineData("foo..")]
|
||||
[InlineData("foo.")]
|
||||
[InlineData("foo..bar")]
|
||||
[InlineData("foo...bar")]
|
||||
public void MatchWithEmptyTokens_HandlesEdgeCase(string subject)
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.Insert(">", 1);
|
||||
CountMatches(s, subject).ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistHasInterest server/gsl/gsl_test.go:180
|
||||
[Fact]
|
||||
public void HasInterest_ReturnsTrueForMatchingSubjects()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.Insert("foo", 11);
|
||||
|
||||
// Expect to find that "foo" matches but "bar" doesn't.
|
||||
s.HasInterest("foo").ShouldBeTrue();
|
||||
s.HasInterest("bar").ShouldBeFalse();
|
||||
|
||||
// Call Match on a subject we know there is no match.
|
||||
CountMatches(s, "bar").ShouldBe(0);
|
||||
s.HasInterest("bar").ShouldBeFalse();
|
||||
|
||||
// Remove fooSub and check interest again
|
||||
s.Remove("foo", 11);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
|
||||
// Try with partial wildcard *
|
||||
s.Insert("foo.*", 22);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar").ShouldBeTrue();
|
||||
s.HasInterest("foo.bar.baz").ShouldBeFalse();
|
||||
|
||||
// Remove sub, there should be no interest
|
||||
s.Remove("foo.*", 22);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar.baz").ShouldBeFalse();
|
||||
|
||||
// Try with full wildcard >
|
||||
s.Insert("foo.>", 33);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar").ShouldBeTrue();
|
||||
s.HasInterest("foo.bar.baz").ShouldBeTrue();
|
||||
|
||||
s.Remove("foo.>", 33);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar.baz").ShouldBeFalse();
|
||||
|
||||
// Try with *.>
|
||||
s.Insert("*.>", 44);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar").ShouldBeTrue();
|
||||
s.HasInterest("foo.baz").ShouldBeTrue();
|
||||
s.Remove("*.>", 44);
|
||||
|
||||
// Try with *.bar
|
||||
s.Insert("*.bar", 55);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar").ShouldBeTrue();
|
||||
s.HasInterest("foo.baz").ShouldBeFalse();
|
||||
s.Remove("*.bar", 55);
|
||||
|
||||
// Try with *
|
||||
s.Insert("*", 66);
|
||||
s.HasInterest("foo").ShouldBeTrue();
|
||||
s.HasInterest("foo.bar").ShouldBeFalse();
|
||||
s.Remove("*", 66);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistHasInterestOverlapping server/gsl/gsl_test.go:237
|
||||
[Fact]
|
||||
public void HasInterestOverlapping_HandlesOverlap()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.Insert("stream.A.child", 11);
|
||||
s.Insert("stream.*", 11);
|
||||
s.HasInterest("stream.A.child").ShouldBeTrue();
|
||||
s.HasInterest("stream.A").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistHasInterestStartingInRace server/gsl/gsl_test.go:247
|
||||
[Fact]
|
||||
public async Task HasInterestStartingIn_ThreadSafe()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
|
||||
// Pre-populate with some patterns
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
s.Insert("foo.bar.baz", i);
|
||||
s.Insert("foo.*.baz", i + 10);
|
||||
s.Insert("foo.>", i + 20);
|
||||
}
|
||||
|
||||
const int iterations = 1000;
|
||||
var tasks = new List<Task>();
|
||||
|
||||
// Task 1: repeatedly call HasInterestStartingIn
|
||||
tasks.Add(Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
s.HasInterestStartingIn("foo");
|
||||
s.HasInterestStartingIn("foo.bar");
|
||||
s.HasInterestStartingIn("foo.bar.baz");
|
||||
s.HasInterestStartingIn("other.subject");
|
||||
}
|
||||
}));
|
||||
|
||||
// Task 2: repeatedly modify the sublist
|
||||
tasks.Add(Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
var val = 1000 + i;
|
||||
var ch = (char)('a' + (i % 26));
|
||||
s.Insert($"test.subject.{ch}", val);
|
||||
s.Insert("foo.*.test", val);
|
||||
s.Remove($"test.subject.{ch}", val);
|
||||
s.Remove("foo.*.test", val);
|
||||
}
|
||||
}));
|
||||
|
||||
// Task 3: also call HasInterest (which does lock)
|
||||
tasks.Add(Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
s.HasInterest("foo.bar.baz");
|
||||
s.HasInterest("foo.something.baz");
|
||||
}
|
||||
}));
|
||||
|
||||
// Wait for all tasks - should not throw (no deadlocks or data races)
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
// Go: TestGenericSublistNumInterest server/gsl/gsl_test.go:298
|
||||
[Fact]
|
||||
public void NumInterest_CountsMatchingSubscriptions()
|
||||
{
|
||||
var s = new GenericSubjectList<int>();
|
||||
s.Insert("foo", 11);
|
||||
|
||||
// Helper to check both Match count and NumInterest agree
|
||||
void RequireNumInterest(string subj, int expected)
|
||||
{
|
||||
CountMatches(s, subj).ShouldBe(expected);
|
||||
s.NumInterest(subj).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// Expect to find that "foo" matches but "bar" doesn't.
|
||||
RequireNumInterest("foo", 1);
|
||||
RequireNumInterest("bar", 0);
|
||||
|
||||
// Remove fooSub and check interest again
|
||||
s.Remove("foo", 11);
|
||||
RequireNumInterest("foo", 0);
|
||||
|
||||
// Try with partial wildcard *
|
||||
s.Insert("foo.*", 22);
|
||||
RequireNumInterest("foo", 0);
|
||||
RequireNumInterest("foo.bar", 1);
|
||||
RequireNumInterest("foo.bar.baz", 0);
|
||||
|
||||
// Remove sub, there should be no interest
|
||||
s.Remove("foo.*", 22);
|
||||
RequireNumInterest("foo", 0);
|
||||
RequireNumInterest("foo.bar", 0);
|
||||
RequireNumInterest("foo.bar.baz", 0);
|
||||
|
||||
// Full wildcard >
|
||||
s.Insert("foo.>", 33);
|
||||
RequireNumInterest("foo", 0);
|
||||
RequireNumInterest("foo.bar", 1);
|
||||
RequireNumInterest("foo.bar.baz", 1);
|
||||
|
||||
s.Remove("foo.>", 33);
|
||||
RequireNumInterest("foo", 0);
|
||||
RequireNumInterest("foo.bar", 0);
|
||||
RequireNumInterest("foo.bar.baz", 0);
|
||||
|
||||
// *.>
|
||||
s.Insert("*.>", 44);
|
||||
RequireNumInterest("foo", 0);
|
||||
RequireNumInterest("foo.bar", 1);
|
||||
RequireNumInterest("foo.bar.baz", 1);
|
||||
s.Remove("*.>", 44);
|
||||
|
||||
// *.bar
|
||||
s.Insert("*.bar", 55);
|
||||
RequireNumInterest("foo", 0);
|
||||
RequireNumInterest("foo.bar", 1);
|
||||
RequireNumInterest("foo.bar.baz", 0);
|
||||
s.Remove("*.bar", 55);
|
||||
|
||||
// *
|
||||
s.Insert("*", 66);
|
||||
RequireNumInterest("foo", 1);
|
||||
RequireNumInterest("foo.bar", 0);
|
||||
s.Remove("*", 66);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text;
|
||||
using NATS.Server.Internal.Avl;
|
||||
using NATS.Server.Internal.Gsl;
|
||||
using NATS.Server.Internal.SubjectTree;
|
||||
using NATS.Server.Internal.SysMem;
|
||||
using NATS.Server.Internal.TimeHashWheel;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Internal;
|
||||
|
||||
public class InternalDsParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void SubjectTreeHelper_IntersectGSL_matches_interested_subjects_once()
|
||||
{
|
||||
var tree = new SubjectTree<int>();
|
||||
tree.Insert("foo.bar"u8.ToArray(), 1);
|
||||
tree.Insert("foo.baz"u8.ToArray(), 2);
|
||||
tree.Insert("other.subject"u8.ToArray(), 3);
|
||||
|
||||
var sublist = new GenericSubjectList<int>();
|
||||
sublist.Insert("foo.*", 1);
|
||||
sublist.Insert("foo.bar", 2); // overlap should not duplicate callback for same subject
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
SubjectTreeHelper.IntersectGSL(tree, sublist, (subject, _) =>
|
||||
{
|
||||
seen.Add(Encoding.UTF8.GetString(subject));
|
||||
});
|
||||
|
||||
seen.Count.ShouldBe(2);
|
||||
seen.ShouldContain("foo.bar");
|
||||
seen.ShouldContain("foo.baz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubjectTree_Dump_outputs_node_and_leaf_structure()
|
||||
{
|
||||
var tree = new SubjectTree<int>();
|
||||
tree.Insert("foo.bar"u8.ToArray(), 1);
|
||||
tree.Insert("foo.baz"u8.ToArray(), 2);
|
||||
|
||||
using var sw = new StringWriter();
|
||||
tree.Dump(sw);
|
||||
var dump = sw.ToString();
|
||||
|
||||
dump.ShouldContain("NODE");
|
||||
dump.ShouldContain("LEAF");
|
||||
dump.ShouldContain("Prefix:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SequenceSet_Encode_supports_destination_buffer_reuse()
|
||||
{
|
||||
var set = new SequenceSet();
|
||||
set.Insert(1);
|
||||
set.Insert(65);
|
||||
set.Insert(1024);
|
||||
|
||||
var buffer = new byte[set.EncodeLength() + 32];
|
||||
var written = set.Encode(buffer);
|
||||
written.ShouldBe(set.EncodeLength());
|
||||
|
||||
var (decoded, bytesRead) = SequenceSet.Decode(buffer.AsSpan(0, written));
|
||||
bytesRead.ShouldBe(written);
|
||||
decoded.Exists(1).ShouldBeTrue();
|
||||
decoded.Exists(65).ShouldBeTrue();
|
||||
decoded.Exists(1024).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashWheelEntry_struct_exposes_sequence_and_expiration()
|
||||
{
|
||||
var entry = new HashWheel.HashWheelEntry(42, 99);
|
||||
entry.Sequence.ShouldBe((ulong)42);
|
||||
entry.Expires.ShouldBe(99);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemMemory_returns_positive_memory_value()
|
||||
{
|
||||
SystemMemory.Memory().ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SimpleSubjectList_works_with_empty_marker_values()
|
||||
{
|
||||
var list = new SimpleSubjectList();
|
||||
list.Insert("foo.bar", new SimpleSublistValue());
|
||||
list.HasInterest("foo.bar").ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Monitoring;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Internal;
|
||||
|
||||
public class InternalDsPeriodicSamplerParityTests
|
||||
{
|
||||
[Fact]
|
||||
[SlopwatchSuppress("SW004", "Test must observe a real 1-second CPU sampling timer tick; wall-clock elapsed time is the observable under test")]
|
||||
public async Task VarzHandler_uses_periodic_background_cpu_sampler()
|
||||
{
|
||||
var options = new NatsOptions { Host = "127.0.0.1", Port = 0 };
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var handler = new VarzHandler(server, options, NullLoggerFactory.Instance);
|
||||
var field = typeof(VarzHandler).GetField("_lastCpuSampleTime", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
field.ShouldNotBeNull();
|
||||
|
||||
var before = (DateTime)field!.GetValue(handler)!;
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(1200));
|
||||
var after = (DateTime)field.GetValue(handler)!;
|
||||
|
||||
after.ShouldBeGreaterThan(before);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Events;
|
||||
using NATS.Server.Internal;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class MessageTraceContextTests
|
||||
{
|
||||
private static ReadOnlyMemory<byte> 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<byte>.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<byte>.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<MsgTraceIngress>();
|
||||
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<MsgTraceIngress>();
|
||||
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<MsgTraceSubjectMapping>();
|
||||
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<MsgTraceEgress>();
|
||||
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<MsgTraceEgress>();
|
||||
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<MsgTraceEgress>();
|
||||
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<MsgTraceEgress>();
|
||||
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<MsgTraceEgress>();
|
||||
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<MsgTraceStreamExport>();
|
||||
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<MsgTraceServiceImport>();
|
||||
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<MsgTraceJetStreamEntry>();
|
||||
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<MsgTraceJetStreamEntry>();
|
||||
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<MsgTraceJetStreamEntry>();
|
||||
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<MsgTraceEgress>();
|
||||
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<MsgTraceEntry>(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<MsgTraceEntry>(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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,321 @@
|
||||
// Go reference: server/thw/thw_test.go
|
||||
|
||||
using NATS.Server.Internal.TimeHashWheel;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Internal.TimeHashWheel;
|
||||
|
||||
public class HashWheelTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper to produce nanosecond timestamps relative to a base, matching
|
||||
/// the Go test pattern of now.Add(N * time.Second).UnixNano().
|
||||
/// </summary>
|
||||
private static long NowNanos() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000;
|
||||
|
||||
private static long SecondsToNanos(long seconds) => seconds * 1_000_000_000;
|
||||
|
||||
// Go: TestHashWheelBasics server/thw/thw_test.go:22
|
||||
[Fact]
|
||||
public void Basics_AddRemoveCount()
|
||||
{
|
||||
var hw = new HashWheel();
|
||||
var now = NowNanos();
|
||||
|
||||
// Add a sequence.
|
||||
ulong seq = 1;
|
||||
var expires = now + SecondsToNanos(5);
|
||||
hw.Add(seq, expires);
|
||||
hw.Count.ShouldBe(1UL);
|
||||
|
||||
// Try to remove non-existent sequence.
|
||||
hw.Remove(999, expires).ShouldBeFalse();
|
||||
hw.Count.ShouldBe(1UL);
|
||||
|
||||
// Remove the sequence properly.
|
||||
hw.Remove(seq, expires).ShouldBeTrue();
|
||||
hw.Count.ShouldBe(0UL);
|
||||
|
||||
// Verify it's gone.
|
||||
hw.Remove(seq, expires).ShouldBeFalse();
|
||||
hw.Count.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
// Go: TestHashWheelUpdate server/thw/thw_test.go:44
|
||||
[Fact]
|
||||
public void Update_ChangesExpiration()
|
||||
{
|
||||
var hw = new HashWheel();
|
||||
var now = NowNanos();
|
||||
var oldExpires = now + SecondsToNanos(5);
|
||||
var newExpires = now + SecondsToNanos(10);
|
||||
|
||||
// Add initial sequence.
|
||||
hw.Add(1, oldExpires);
|
||||
hw.Count.ShouldBe(1UL);
|
||||
|
||||
// Update expiration.
|
||||
hw.Update(1, oldExpires, newExpires);
|
||||
hw.Count.ShouldBe(1UL);
|
||||
|
||||
// Verify old expiration is gone.
|
||||
hw.Remove(1, oldExpires).ShouldBeFalse();
|
||||
hw.Count.ShouldBe(1UL);
|
||||
|
||||
// Verify new expiration exists.
|
||||
hw.Remove(1, newExpires).ShouldBeTrue();
|
||||
hw.Count.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
// Go: TestHashWheelExpiration server/thw/thw_test.go:67
|
||||
[Fact]
|
||||
public void Expiration_FiresCallbackForExpired()
|
||||
{
|
||||
var hw = new HashWheel();
|
||||
var now = NowNanos();
|
||||
|
||||
// Add sequences with different expiration times.
|
||||
var seqs = new Dictionary<ulong, long>
|
||||
{
|
||||
[1] = now - SecondsToNanos(1), // Already expired
|
||||
[2] = now + SecondsToNanos(1), // Expires soon
|
||||
[3] = now + SecondsToNanos(10), // Expires later
|
||||
[4] = now + SecondsToNanos(60), // Expires much later
|
||||
};
|
||||
|
||||
foreach (var (seq, expires) in seqs)
|
||||
{
|
||||
hw.Add(seq, expires);
|
||||
}
|
||||
|
||||
hw.Count.ShouldBe((ulong)seqs.Count);
|
||||
|
||||
// Process expired tasks using internal method with explicit "now" timestamp.
|
||||
var expired = new Dictionary<ulong, bool>();
|
||||
hw.ExpireTasksInternal(now, (seq, _) =>
|
||||
{
|
||||
expired[seq] = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Verify only sequence 1 expired.
|
||||
expired.Count.ShouldBe(1);
|
||||
expired.ShouldContainKey(1UL);
|
||||
hw.Count.ShouldBe(3UL);
|
||||
}
|
||||
|
||||
// Go: TestHashWheelManualExpiration server/thw/thw_test.go:97
|
||||
[Fact]
|
||||
public void ManualExpiration_SpecificTime()
|
||||
{
|
||||
var hw = new HashWheel();
|
||||
var now = NowNanos();
|
||||
|
||||
for (ulong seq = 1; seq <= 4; seq++)
|
||||
{
|
||||
hw.Add(seq, now);
|
||||
}
|
||||
|
||||
hw.Count.ShouldBe(4UL);
|
||||
|
||||
// Loop over expired multiple times, but without removing them.
|
||||
var expired = new Dictionary<ulong, ulong>();
|
||||
for (ulong i = 0; i <= 1; i++)
|
||||
{
|
||||
hw.ExpireTasksInternal(now, (seq, _) =>
|
||||
{
|
||||
if (!expired.TryGetValue(seq, out var count))
|
||||
{
|
||||
count = 0;
|
||||
}
|
||||
|
||||
expired[seq] = count + 1;
|
||||
return false;
|
||||
});
|
||||
|
||||
expired.Count.ShouldBe(4);
|
||||
expired[1].ShouldBe(1 + i);
|
||||
expired[2].ShouldBe(1 + i);
|
||||
expired[3].ShouldBe(1 + i);
|
||||
expired[4].ShouldBe(1 + i);
|
||||
hw.Count.ShouldBe(4UL);
|
||||
}
|
||||
|
||||
// Only remove even sequences.
|
||||
for (ulong i = 0; i <= 1; i++)
|
||||
{
|
||||
hw.ExpireTasksInternal(now, (seq, _) =>
|
||||
{
|
||||
if (!expired.TryGetValue(seq, out var count))
|
||||
{
|
||||
count = 0;
|
||||
}
|
||||
|
||||
expired[seq] = count + 1;
|
||||
return seq % 2 == 0;
|
||||
});
|
||||
|
||||
// Verify even sequences are removed.
|
||||
expired[1].ShouldBe(3 + i);
|
||||
expired[2].ShouldBe(3UL);
|
||||
expired[3].ShouldBe(3 + i);
|
||||
expired[4].ShouldBe(3UL);
|
||||
hw.Count.ShouldBe(2UL);
|
||||
}
|
||||
|
||||
// Manually remove last items.
|
||||
hw.Remove(1, now).ShouldBeTrue();
|
||||
hw.Remove(3, now).ShouldBeTrue();
|
||||
hw.Count.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
// Go: TestHashWheelExpirationLargerThanWheel server/thw/thw_test.go:143
|
||||
[Fact]
|
||||
public void LargerThanWheel_HandlesWrapAround()
|
||||
{
|
||||
var hw = new HashWheel();
|
||||
|
||||
// Add sequences such that they can be expired immediately.
|
||||
var seqs = new Dictionary<ulong, long>
|
||||
{
|
||||
[1] = 0,
|
||||
[2] = SecondsToNanos(1),
|
||||
};
|
||||
|
||||
foreach (var (seq, expires) in seqs)
|
||||
{
|
||||
hw.Add(seq, expires);
|
||||
}
|
||||
|
||||
hw.Count.ShouldBe(2UL);
|
||||
|
||||
// Pick a timestamp such that the expiration needs to wrap around the whole wheel.
|
||||
// Go: now := int64(time.Second) * wheelMask
|
||||
var now = SecondsToNanos(1) * HashWheel.WheelSize - SecondsToNanos(1);
|
||||
|
||||
// Process expired tasks.
|
||||
var expired = new Dictionary<ulong, bool>();
|
||||
hw.ExpireTasksInternal(now, (seq, _) =>
|
||||
{
|
||||
expired[seq] = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Verify both sequences are expired.
|
||||
expired.Count.ShouldBe(2);
|
||||
hw.Count.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
// Go: TestHashWheelNextExpiration server/thw/thw_test.go:171
|
||||
[Fact]
|
||||
public void NextExpiration_FindsEarliest()
|
||||
{
|
||||
var hw = new HashWheel();
|
||||
var now = NowNanos();
|
||||
|
||||
// Add sequences with different expiration times.
|
||||
var seqs = new Dictionary<ulong, long>
|
||||
{
|
||||
[1] = now + SecondsToNanos(5),
|
||||
[2] = now + SecondsToNanos(3), // Earliest
|
||||
[3] = now + SecondsToNanos(10),
|
||||
};
|
||||
|
||||
foreach (var (seq, expires) in seqs)
|
||||
{
|
||||
hw.Add(seq, expires);
|
||||
}
|
||||
|
||||
hw.Count.ShouldBe((ulong)seqs.Count);
|
||||
|
||||
// Test GetNextExpiration.
|
||||
var nextExternalTick = now + SecondsToNanos(6);
|
||||
// Should return sequence 2's expiration.
|
||||
hw.GetNextExpiration(nextExternalTick).ShouldBe(seqs[2]);
|
||||
|
||||
// Test with empty wheel.
|
||||
var empty = new HashWheel();
|
||||
empty.GetNextExpiration(now + SecondsToNanos(1)).ShouldBe(long.MaxValue);
|
||||
}
|
||||
|
||||
// Go: TestHashWheelStress server/thw/thw_test.go:197
|
||||
[Fact]
|
||||
public void Stress_ConcurrentAddRemove()
|
||||
{
|
||||
var hw = new HashWheel();
|
||||
var now = NowNanos();
|
||||
const int numSequences = 100_000;
|
||||
|
||||
// Add many sequences.
|
||||
for (var seq = 0; seq < numSequences; seq++)
|
||||
{
|
||||
var expires = now + SecondsToNanos(seq);
|
||||
hw.Add((ulong)seq, expires);
|
||||
}
|
||||
|
||||
// Update many sequences (every other one).
|
||||
for (var seq = 0; seq < numSequences; seq += 2)
|
||||
{
|
||||
var oldExpires = now + SecondsToNanos(seq);
|
||||
var newExpires = now + SecondsToNanos(seq + numSequences);
|
||||
hw.Update((ulong)seq, oldExpires, newExpires);
|
||||
}
|
||||
|
||||
// Remove odd-numbered sequences.
|
||||
for (var seq = 1; seq < numSequences; seq += 2)
|
||||
{
|
||||
var expires = now + SecondsToNanos(seq);
|
||||
hw.Remove((ulong)seq, expires).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// After updates and removals, only half remain (the even ones with updated expiration).
|
||||
hw.Count.ShouldBe((ulong)(numSequences / 2));
|
||||
}
|
||||
|
||||
// Go: TestHashWheelEncodeDecode server/thw/thw_test.go:222
|
||||
[Fact]
|
||||
public void EncodeDecode_RoundTrips()
|
||||
{
|
||||
var hw = new HashWheel();
|
||||
var now = NowNanos();
|
||||
const int numSequences = 100_000;
|
||||
|
||||
// Add many sequences.
|
||||
for (var seq = 0; seq < numSequences; seq++)
|
||||
{
|
||||
var expires = now + SecondsToNanos(seq);
|
||||
hw.Add((ulong)seq, expires);
|
||||
}
|
||||
|
||||
var encoded = hw.Encode(12345);
|
||||
encoded.Length.ShouldBeGreaterThan(17); // Bigger than just the header.
|
||||
|
||||
var nhw = new HashWheel();
|
||||
var (highSeq, bytesRead) = nhw.Decode(encoded);
|
||||
highSeq.ShouldBe(12345UL);
|
||||
bytesRead.ShouldBe(encoded.Length);
|
||||
hw.GetNextExpiration(long.MaxValue).ShouldBe(nhw.GetNextExpiration(long.MaxValue));
|
||||
|
||||
// Verify all slots match.
|
||||
for (var s = 0; s < HashWheel.WheelSize; s++)
|
||||
{
|
||||
var slot = hw.Wheel[s];
|
||||
var nslot = nhw.Wheel[s];
|
||||
|
||||
if (slot is null)
|
||||
{
|
||||
nslot.ShouldBeNull();
|
||||
continue;
|
||||
}
|
||||
|
||||
nslot.ShouldNotBeNull();
|
||||
slot.Lowest.ShouldBe(nslot!.Lowest);
|
||||
slot.Entries.Count.ShouldBe(nslot.Entries.Count);
|
||||
|
||||
foreach (var (seq, ts) in slot.Entries)
|
||||
{
|
||||
nslot.Entries.ShouldContainKey(seq);
|
||||
nslot.Entries[seq].ShouldBe(ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Text;
|
||||
using NATS.Server.Internal;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for TraceContextPropagator: trace creation, header injection/extraction,
|
||||
/// child span creation, round-trip fidelity, and ShouldTrace detection.
|
||||
/// Go reference: server/msgtrace.go — trace context embedding and extraction.
|
||||
/// </summary>
|
||||
public class TraceContextPropagationTests
|
||||
{
|
||||
// Helper: build a minimal NATS/1.0 header block with the given headers.
|
||||
private static byte[] BuildNatsHeaders(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());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTrace_GeneratesValidContext()
|
||||
{
|
||||
var ctx = TraceContextPropagator.CreateTrace("abc123", "span456", destination: "trace.dest");
|
||||
|
||||
ctx.TraceId.ShouldBe("abc123");
|
||||
ctx.SpanId.ShouldBe("span456");
|
||||
ctx.Destination.ShouldBe("trace.dest");
|
||||
ctx.TraceOnly.ShouldBeFalse();
|
||||
ctx.CreatedAt.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-5), DateTime.UtcNow.AddSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTrace_ValidHeaders_ReturnsContext()
|
||||
{
|
||||
var headers = BuildNatsHeaders((TraceContextPropagator.TraceParentHeader, "trace1-span1"));
|
||||
|
||||
var ctx = TraceContextPropagator.ExtractTrace(headers);
|
||||
|
||||
ctx.ShouldNotBeNull();
|
||||
ctx.TraceId.ShouldBe("trace1");
|
||||
ctx.SpanId.ShouldBe("span1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTrace_NoTraceHeader_ReturnsNull()
|
||||
{
|
||||
var headers = BuildNatsHeaders(("Content-Type", "text/plain"));
|
||||
|
||||
var ctx = TraceContextPropagator.ExtractTrace(headers);
|
||||
|
||||
ctx.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectTrace_AppendsToHeaders()
|
||||
{
|
||||
var existing = BuildNatsHeaders(("Content-Type", "text/plain"));
|
||||
var ctx = TraceContextPropagator.CreateTrace("tid", "sid");
|
||||
|
||||
var result = TraceContextPropagator.InjectTrace(ctx, existing);
|
||||
|
||||
var text = Encoding.ASCII.GetString(result);
|
||||
text.ShouldContain($"{TraceContextPropagator.TraceParentHeader}: tid-sid");
|
||||
text.ShouldContain("Content-Type: text/plain");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectTrace_EmptyHeaders_CreatesNew()
|
||||
{
|
||||
var ctx = TraceContextPropagator.CreateTrace("newtrace", "newspan");
|
||||
|
||||
var result = TraceContextPropagator.InjectTrace(ctx, ReadOnlySpan<byte>.Empty);
|
||||
|
||||
var text = Encoding.ASCII.GetString(result);
|
||||
text.ShouldStartWith("NATS/1.0\r\n");
|
||||
text.ShouldContain($"{TraceContextPropagator.TraceParentHeader}: newtrace-newspan");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateChildSpan_PreservesTraceId()
|
||||
{
|
||||
var parent = TraceContextPropagator.CreateTrace("parentTrace", "parentSpan");
|
||||
|
||||
var child = TraceContextPropagator.CreateChildSpan(parent, "childSpan");
|
||||
|
||||
child.TraceId.ShouldBe("parentTrace");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateChildSpan_NewSpanId()
|
||||
{
|
||||
var parent = TraceContextPropagator.CreateTrace("parentTrace", "parentSpan");
|
||||
|
||||
var child = TraceContextPropagator.CreateChildSpan(parent, "childSpan");
|
||||
|
||||
child.SpanId.ShouldBe("childSpan");
|
||||
child.SpanId.ShouldNotBe(parent.SpanId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldTrace_WithHeader_ReturnsTrue()
|
||||
{
|
||||
var headers = BuildNatsHeaders((TraceContextPropagator.TraceParentHeader, "trace1-span1"));
|
||||
|
||||
TraceContextPropagator.ShouldTrace(headers).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldTrace_WithoutHeader_ReturnsFalse()
|
||||
{
|
||||
var headers = BuildNatsHeaders(("Content-Type", "text/plain"));
|
||||
|
||||
TraceContextPropagator.ShouldTrace(headers).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_CreateInjectExtract_Matches()
|
||||
{
|
||||
// Use hex-style IDs (no dashes) so the "{traceId}-{spanId}" wire format
|
||||
// can be unambiguously split on the single separator dash.
|
||||
var original = TraceContextPropagator.CreateTrace("0af7651916cd43dd8448eb211c80319c", "b7ad6b7169203331", destination: "trace.dest");
|
||||
|
||||
// Inject into empty headers
|
||||
var injected = TraceContextPropagator.InjectTrace(original, ReadOnlySpan<byte>.Empty);
|
||||
|
||||
// Extract back from the injected headers
|
||||
var extracted = TraceContextPropagator.ExtractTrace(injected);
|
||||
|
||||
extracted.ShouldNotBeNull();
|
||||
extracted.TraceId.ShouldBe(original.TraceId);
|
||||
extracted.SpanId.ShouldBe(original.SpanId);
|
||||
}
|
||||
}
|
||||
85
tests/NATS.Server.Core.Tests/InternalClientTests.cs
Normal file
85
tests/NATS.Server.Core.Tests/InternalClientTests.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class InternalClientTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ClientKind.Client, false)]
|
||||
[InlineData(ClientKind.Router, false)]
|
||||
[InlineData(ClientKind.Gateway, false)]
|
||||
[InlineData(ClientKind.Leaf, false)]
|
||||
[InlineData(ClientKind.System, true)]
|
||||
[InlineData(ClientKind.JetStream, true)]
|
||||
[InlineData(ClientKind.Account, true)]
|
||||
public void IsInternal_returns_correct_value(ClientKind kind, bool expected)
|
||||
{
|
||||
kind.IsInternal().ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsClient_implements_INatsClient()
|
||||
{
|
||||
typeof(NatsClient).GetInterfaces().ShouldContain(typeof(INatsClient));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsClient_kind_is_Client()
|
||||
{
|
||||
typeof(NatsClient).GetProperty("Kind")!.PropertyType.ShouldBe(typeof(ClientKind));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InternalClient_system_kind()
|
||||
{
|
||||
var account = new Account("$SYS");
|
||||
var client = new InternalClient(1, ClientKind.System, account);
|
||||
client.Kind.ShouldBe(ClientKind.System);
|
||||
client.IsInternal.ShouldBeTrue();
|
||||
client.Id.ShouldBe(1UL);
|
||||
client.Account.ShouldBe(account);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InternalClient_account_kind()
|
||||
{
|
||||
var account = new Account("myaccount");
|
||||
var client = new InternalClient(2, ClientKind.Account, account);
|
||||
client.Kind.ShouldBe(ClientKind.Account);
|
||||
client.IsInternal.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InternalClient_rejects_non_internal_kind()
|
||||
{
|
||||
var account = new Account("test");
|
||||
Should.Throw<ArgumentException>(() => new InternalClient(1, ClientKind.Client, account));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InternalClient_SendMessage_invokes_callback()
|
||||
{
|
||||
var account = new Account("$SYS");
|
||||
var client = new InternalClient(1, ClientKind.System, account);
|
||||
string? capturedSubject = null;
|
||||
string? capturedSid = null;
|
||||
client.MessageCallback = (subject, sid, replyTo, headers, payload) =>
|
||||
{
|
||||
capturedSubject = subject;
|
||||
capturedSid = sid;
|
||||
};
|
||||
|
||||
client.SendMessage("test.subject", "1", null, ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
|
||||
|
||||
capturedSubject.ShouldBe("test.subject");
|
||||
capturedSid.ShouldBe("1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InternalClient_QueueOutbound_returns_true_noop()
|
||||
{
|
||||
var account = new Account("$SYS");
|
||||
var client = new InternalClient(1, ClientKind.System, account);
|
||||
client.QueueOutbound(ReadOnlyMemory<byte>.Empty).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
60
tests/NATS.Server.Core.Tests/LoggingTests.cs
Normal file
60
tests/NATS.Server.Core.Tests/LoggingTests.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using Serilog;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class LoggingTests : IDisposable
|
||||
{
|
||||
private readonly string _logDir;
|
||||
|
||||
public LoggingTests()
|
||||
{
|
||||
_logDir = Path.Combine(Path.GetTempPath(), $"nats-log-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_logDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_logDir, true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void File_sink_creates_log_file()
|
||||
{
|
||||
var logPath = Path.Combine(_logDir, "test.log");
|
||||
|
||||
using var logger = new LoggerConfiguration()
|
||||
.WriteTo.File(logPath)
|
||||
.CreateLogger();
|
||||
|
||||
logger.Information("Hello from test");
|
||||
logger.Dispose();
|
||||
|
||||
File.Exists(logPath).ShouldBeTrue();
|
||||
var content = File.ReadAllText(logPath);
|
||||
content.ShouldContain("Hello from test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void File_sink_rotates_on_size_limit()
|
||||
{
|
||||
var logPath = Path.Combine(_logDir, "rotate.log");
|
||||
|
||||
using var logger = new LoggerConfiguration()
|
||||
.WriteTo.File(
|
||||
logPath,
|
||||
fileSizeLimitBytes: 200,
|
||||
rollOnFileSizeLimit: true,
|
||||
retainedFileCountLimit: 3)
|
||||
.CreateLogger();
|
||||
|
||||
// Write enough to trigger rotation
|
||||
for (int i = 0; i < 50; i++)
|
||||
logger.Information("Log message number {Number} with some padding text", i);
|
||||
|
||||
logger.Dispose();
|
||||
|
||||
// Should have created rotated files
|
||||
var logFiles = Directory.GetFiles(_logDir, "rotate*.log");
|
||||
logFiles.Length.ShouldBeGreaterThan(1);
|
||||
}
|
||||
}
|
||||
562
tests/NATS.Server.Core.Tests/MessageTraceTests.cs
Normal file
562
tests/NATS.Server.Core.Tests/MessageTraceTests.cs
Normal file
@@ -0,0 +1,562 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
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<Socket> 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<string> { $"\"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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MessageTraceContext_empty_has_null_fields()
|
||||
{
|
||||
var ctx = MessageTraceContext.Empty;
|
||||
|
||||
ctx.ClientName.ShouldBeNull();
|
||||
ctx.ClientLang.ShouldBeNull();
|
||||
ctx.ClientVersion.ShouldBeNull();
|
||||
ctx.HeadersEnabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MessageTraceContext.CreateFromConnect with null options returns Empty.
|
||||
/// Go reference: msgtrace_test.go — trace context defaults
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MessageTraceContext_create_from_null_opts_returns_empty()
|
||||
{
|
||||
var ctx = MessageTraceContext.CreateFromConnect(null);
|
||||
|
||||
ctx.ShouldBe(MessageTraceContext.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MessageTraceContext is a record — two instances with the same values are equal.
|
||||
/// Go reference: msgtrace_test.go — deterministic identity comparison
|
||||
/// </summary>
|
||||
[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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// NatsOptions.Trace is false by default. Server-level tracing is opt-in.
|
||||
/// Go reference: opts.go default — trace=false
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NatsOptions_trace_is_false_by_default()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
|
||||
opts.Trace.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NatsOptions.TraceVerbose is false by default.
|
||||
/// Go reference: opts.go — trace_verbose=false
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NatsOptions_trace_verbose_is_false_by_default()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
|
||||
opts.TraceVerbose.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NatsOptions.MaxTracedMsgLen is 0 by default (unlimited).
|
||||
/// Go reference: opts.go — max_traced_msg_len default=0
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NatsOptions_max_traced_msg_len_is_zero_by_default()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
|
||||
opts.MaxTracedMsgLen.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ClientFlagHolder_trace_mode_is_not_set_by_default()
|
||||
{
|
||||
var holder = new ClientFlagHolder();
|
||||
|
||||
holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ClientFlagHolder.SetFlag / ClearFlag toggle TraceMode correctly.
|
||||
/// Go reference: client.go setTraceMode
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TraceMode is independent of other flags — toggling it does not affect
|
||||
/// ConnectReceived or other status bits.
|
||||
/// Go reference: client.go — per-bit flag isolation
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
853
tests/NATS.Server.Core.Tests/MsgTraceGoParityTests.cs
Normal file
853
tests/NATS.Server.Core.Tests/MsgTraceGoParityTests.cs
Normal file
@@ -0,0 +1,853 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
28
tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj
Normal file
28
tests/NATS.Server.Core.Tests/NATS.Server.Core.Tests.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NATS.Client.Core" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="Shouldly" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
|
||||
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
238
tests/NATS.Server.Core.Tests/NatsConfLexerTests.cs
Normal file
238
tests/NATS.Server.Core.Tests/NatsConfLexerTests.cs
Normal file
@@ -0,0 +1,238 @@
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class NatsConfLexerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Lex_SimpleKeyStringValue_ReturnsKeyAndString()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("foo = \"bar\"").ToList();
|
||||
tokens[0].Type.ShouldBe(TokenType.Key);
|
||||
tokens[0].Value.ShouldBe("foo");
|
||||
tokens[1].Type.ShouldBe(TokenType.String);
|
||||
tokens[1].Value.ShouldBe("bar");
|
||||
tokens[2].Type.ShouldBe(TokenType.Eof);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_SingleQuotedString_ReturnsString()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("foo = 'bar'").ToList();
|
||||
tokens[1].Type.ShouldBe(TokenType.String);
|
||||
tokens[1].Value.ShouldBe("bar");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_IntegerValue_ReturnsInteger()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("port = 4222").ToList();
|
||||
tokens[0].Type.ShouldBe(TokenType.Key);
|
||||
tokens[0].Value.ShouldBe("port");
|
||||
tokens[1].Type.ShouldBe(TokenType.Integer);
|
||||
tokens[1].Value.ShouldBe("4222");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_IntegerWithSuffix_ReturnsInteger()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("size = 64mb").ToList();
|
||||
tokens[1].Type.ShouldBe(TokenType.Integer);
|
||||
tokens[1].Value.ShouldBe("64mb");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_BooleanValues_ReturnsBool()
|
||||
{
|
||||
foreach (var val in new[] { "true", "false", "yes", "no", "on", "off" })
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize($"flag = {val}").ToList();
|
||||
tokens[1].Type.ShouldBe(TokenType.Bool);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_FloatValue_ReturnsFloat()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("rate = 2.5").ToList();
|
||||
tokens[1].Type.ShouldBe(TokenType.Float);
|
||||
tokens[1].Value.ShouldBe("2.5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_NegativeNumber_ReturnsInteger()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("offset = -10").ToList();
|
||||
tokens[1].Type.ShouldBe(TokenType.Integer);
|
||||
tokens[1].Value.ShouldBe("-10");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_DatetimeValue_ReturnsDatetime()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("ts = 2024-01-15T10:30:00Z").ToList();
|
||||
tokens[1].Type.ShouldBe(TokenType.DateTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_HashComment_IsIgnored()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("# this is a comment\nfoo = 1").ToList();
|
||||
var keys = tokens.Where(t => t.Type == TokenType.Key).ToList();
|
||||
keys.Count.ShouldBe(1);
|
||||
keys[0].Value.ShouldBe("foo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_CommentBody_EmitsTextToken()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("# this is a comment\nfoo = 1").ToList();
|
||||
var commentBody = tokens.Single(t => t.Type == TokenType.Text);
|
||||
commentBody.Value.ShouldBe(" this is a comment");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_SlashComment_IsIgnored()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("// comment\nfoo = 1").ToList();
|
||||
var keys = tokens.Where(t => t.Type == TokenType.Key).ToList();
|
||||
keys.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_MapBlock_ReturnsMapStartEnd()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("auth { user: admin }").ToList();
|
||||
tokens[0].Type.ShouldBe(TokenType.Key);
|
||||
tokens[0].Value.ShouldBe("auth");
|
||||
tokens[1].Type.ShouldBe(TokenType.MapStart);
|
||||
tokens[2].Type.ShouldBe(TokenType.Key);
|
||||
tokens[2].Value.ShouldBe("user");
|
||||
tokens[3].Type.ShouldBe(TokenType.String);
|
||||
tokens[3].Value.ShouldBe("admin");
|
||||
tokens[4].Type.ShouldBe(TokenType.MapEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_Array_ReturnsArrayStartEnd()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("items = [1, 2, 3]").ToList();
|
||||
tokens[1].Type.ShouldBe(TokenType.ArrayStart);
|
||||
tokens[2].Type.ShouldBe(TokenType.Integer);
|
||||
tokens[2].Value.ShouldBe("1");
|
||||
tokens[5].Type.ShouldBe(TokenType.ArrayEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_Variable_ReturnsVariable()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("secret = $MY_VAR").ToList();
|
||||
tokens[1].Type.ShouldBe(TokenType.Variable);
|
||||
tokens[1].Value.ShouldBe("MY_VAR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_Include_ReturnsInclude()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("include \"auth.conf\"").ToList();
|
||||
tokens[0].Type.ShouldBe(TokenType.Include);
|
||||
tokens[0].Value.ShouldBe("auth.conf");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_EscapeSequences_AreProcessed()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("msg = \"hello\\tworld\\n\"").ToList();
|
||||
tokens[1].Type.ShouldBe(TokenType.String);
|
||||
tokens[1].Value.ShouldBe("hello\tworld\n");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_HexEscape_IsProcessed()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("val = \"\\x41\\x42\"").ToList();
|
||||
tokens[1].Value.ShouldBe("AB");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_ColonSeparator_Works()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("foo: bar").ToList();
|
||||
tokens[0].Type.ShouldBe(TokenType.Key);
|
||||
tokens[1].Type.ShouldBe(TokenType.String);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_WhitespaceSeparator_Works()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("foo bar").ToList();
|
||||
tokens[0].Type.ShouldBe(TokenType.Key);
|
||||
tokens[1].Type.ShouldBe(TokenType.String);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_SemicolonTerminator_IsHandled()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("foo = 1; bar = 2").ToList();
|
||||
var keys = tokens.Where(t => t.Type == TokenType.Key).ToList();
|
||||
keys.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_EmptyInput_ReturnsEof()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("").ToList();
|
||||
tokens.Count.ShouldBe(1);
|
||||
tokens[0].Type.ShouldBe(TokenType.Eof);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_BlockString_ReturnsString()
|
||||
{
|
||||
var input = "desc (\nthis is\na block\n)\n";
|
||||
var tokens = NatsConfLexer.Tokenize(input).ToList();
|
||||
tokens[0].Type.ShouldBe(TokenType.Key);
|
||||
tokens[1].Type.ShouldBe(TokenType.String);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_IPAddress_ReturnsString()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("host = 127.0.0.1").ToList();
|
||||
tokens[1].Type.ShouldBe(TokenType.String);
|
||||
tokens[1].Value.ShouldBe("127.0.0.1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_TrackLineNumbers()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("a = 1\nb = 2\nc = 3").ToList();
|
||||
tokens[0].Line.ShouldBe(1); // a
|
||||
tokens[2].Line.ShouldBe(2); // b
|
||||
tokens[4].Line.ShouldBe(3); // c
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_UnterminatedString_ReturnsError()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("foo = \"unterminated").ToList();
|
||||
tokens.ShouldContain(t => t.Type == TokenType.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_StringStartingWithDigit_TreatedAsString()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("foo = 3xyz").ToList();
|
||||
tokens[1].Type.ShouldBe(TokenType.String);
|
||||
tokens[1].Value.ShouldBe("3xyz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lex_Unicode_surrogate_pairs_in_strings_are_preserved()
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize("msg = \"rocket🚀\"\nport = 4222").ToList();
|
||||
tokens[1].Type.ShouldBe(TokenType.String);
|
||||
tokens[1].Value.ShouldBe("rocket🚀");
|
||||
tokens[2].Line.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
184
tests/NATS.Server.Core.Tests/NatsConfParserTests.cs
Normal file
184
tests/NATS.Server.Core.Tests/NatsConfParserTests.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class NatsConfParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_SimpleTopLevel_ReturnsCorrectTypes()
|
||||
{
|
||||
var result = NatsConfParser.Parse("foo = '1'; bar = 2.2; baz = true; boo = 22");
|
||||
result["foo"].ShouldBe("1");
|
||||
result["bar"].ShouldBe(2.2);
|
||||
result["baz"].ShouldBe(true);
|
||||
result["boo"].ShouldBe(22L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Booleans_AllVariants()
|
||||
{
|
||||
foreach (var (input, expected) in new[] {
|
||||
("true", true), ("TRUE", true), ("yes", true), ("on", true),
|
||||
("false", false), ("FALSE", false), ("no", false), ("off", false)
|
||||
})
|
||||
{
|
||||
var result = NatsConfParser.Parse($"flag = {input}");
|
||||
result["flag"].ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_IntegerWithSuffix_AppliesMultiplier()
|
||||
{
|
||||
var result = NatsConfParser.Parse("a = 1k; b = 2mb; c = 3gb; d = 4kb");
|
||||
result["a"].ShouldBe(1000L);
|
||||
result["b"].ShouldBe(2L * 1024 * 1024);
|
||||
result["c"].ShouldBe(3L * 1024 * 1024 * 1024);
|
||||
result["d"].ShouldBe(4L * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NestedMap_ReturnsDictionary()
|
||||
{
|
||||
var result = NatsConfParser.Parse("auth { user: admin, pass: secret }");
|
||||
var auth = result["auth"].ShouldBeOfType<Dictionary<string, object?>>();
|
||||
auth["user"].ShouldBe("admin");
|
||||
auth["pass"].ShouldBe("secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Array_ReturnsList()
|
||||
{
|
||||
var result = NatsConfParser.Parse("items = [1, 2, 3]");
|
||||
var items = result["items"].ShouldBeOfType<List<object?>>();
|
||||
items.Count.ShouldBe(3);
|
||||
items[0].ShouldBe(1L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Variable_ResolvesFromContext()
|
||||
{
|
||||
var result = NatsConfParser.Parse("index = 22\nfoo = $index");
|
||||
result["foo"].ShouldBe(22L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NestedVariable_UsesBlockScope()
|
||||
{
|
||||
var input = "index = 22\nnest {\n index = 11\n foo = $index\n}\nbar = $index";
|
||||
var result = NatsConfParser.Parse(input);
|
||||
var nest = result["nest"].ShouldBeOfType<Dictionary<string, object?>>();
|
||||
nest["foo"].ShouldBe(11L);
|
||||
result["bar"].ShouldBe(22L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_EnvironmentVariable_ResolvesFromEnv()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("NATS_TEST_VAR_12345", "hello");
|
||||
try
|
||||
{
|
||||
var result = NatsConfParser.Parse("val = $NATS_TEST_VAR_12345");
|
||||
result["val"].ShouldBe("hello");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("NATS_TEST_VAR_12345", null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UndefinedVariable_Throws()
|
||||
{
|
||||
Should.Throw<FormatException>(() =>
|
||||
NatsConfParser.Parse("val = $UNDEFINED_VAR_XYZZY_99999"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_IncludeDirective_MergesFile()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "main.conf"), "port = 4222\ninclude \"sub.conf\"");
|
||||
File.WriteAllText(Path.Combine(dir, "sub.conf"), "host = \"localhost\"");
|
||||
var result = NatsConfParser.ParseFile(Path.Combine(dir, "main.conf"));
|
||||
result["port"].ShouldBe(4222L);
|
||||
result["host"].ShouldBe("localhost");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MultipleKeySeparators_AllWork()
|
||||
{
|
||||
var r1 = NatsConfParser.Parse("a = 1");
|
||||
var r2 = NatsConfParser.Parse("a : 1");
|
||||
var r3 = NatsConfParser.Parse("a 1");
|
||||
r1["a"].ShouldBe(1L);
|
||||
r2["a"].ShouldBe(1L);
|
||||
r3["a"].ShouldBe(1L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ErrorOnInvalidInput_Throws()
|
||||
{
|
||||
Should.Throw<FormatException>(() => NatsConfParser.Parse("= invalid"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CommentsInsideBlocks_AreIgnored()
|
||||
{
|
||||
var input = "auth {\n # comment\n user: admin\n // another comment\n pass: secret\n}";
|
||||
var result = NatsConfParser.Parse(input);
|
||||
var auth = result["auth"].ShouldBeOfType<Dictionary<string, object?>>();
|
||||
auth["user"].ShouldBe("admin");
|
||||
auth["pass"].ShouldBe("secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ArrayOfMaps_Works()
|
||||
{
|
||||
var input = "users = [\n { user: alice, pass: pw1 }\n { user: bob, pass: pw2 }\n]";
|
||||
var result = NatsConfParser.Parse(input);
|
||||
var users = result["users"].ShouldBeOfType<List<object?>>();
|
||||
users.Count.ShouldBe(2);
|
||||
var first = users[0].ShouldBeOfType<Dictionary<string, object?>>();
|
||||
first["user"].ShouldBe("alice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_BcryptPassword_HandledAsString()
|
||||
{
|
||||
var input = "pass = $2a$04$P/.bd.7unw9Ew7yWJqXsl.f4oNRLQGvadEL2YnqQXbbb.IVQajRdK";
|
||||
var result = NatsConfParser.Parse(input);
|
||||
((string)result["pass"]!).ShouldStartWith("$2a$");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_WithDigest_ReturnsStableHash()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var conf = Path.Combine(dir, "test.conf");
|
||||
File.WriteAllText(conf, "port = 4222\nhost = \"localhost\"");
|
||||
var (result, digest) = NatsConfParser.ParseFileWithDigest(conf);
|
||||
result["port"].ShouldBe(4222L);
|
||||
digest.ShouldStartWith("sha256:");
|
||||
digest.Length.ShouldBeGreaterThan(10);
|
||||
|
||||
var (_, digest2) = NatsConfParser.ParseFileWithDigest(conf);
|
||||
digest2.ShouldBe(digest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
tests/NATS.Server.Core.Tests/NatsHeaderParserTests.cs
Normal file
51
tests/NATS.Server.Core.Tests/NatsHeaderParserTests.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class NatsHeaderParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_status_line_only()
|
||||
{
|
||||
var input = "NATS/1.0 503\r\n\r\n"u8;
|
||||
var result = NatsHeaderParser.Parse(input);
|
||||
result.Status.ShouldBe(503);
|
||||
result.Description.ShouldBeEmpty();
|
||||
result.Headers.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_status_with_description()
|
||||
{
|
||||
var input = "NATS/1.0 503 No Responders\r\n\r\n"u8;
|
||||
var result = NatsHeaderParser.Parse(input);
|
||||
result.Status.ShouldBe(503);
|
||||
result.Description.ShouldBe("No Responders");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_headers_with_values()
|
||||
{
|
||||
var input = "NATS/1.0\r\nFoo: bar\r\nBaz: qux\r\n\r\n"u8;
|
||||
var result = NatsHeaderParser.Parse(input);
|
||||
result.Status.ShouldBe(0);
|
||||
result.Headers["Foo"].ShouldBe(["bar"]);
|
||||
result.Headers["Baz"].ShouldBe(["qux"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_multi_value_header()
|
||||
{
|
||||
var input = "NATS/1.0\r\nX-Tag: a\r\nX-Tag: b\r\n\r\n"u8;
|
||||
var result = NatsHeaderParser.Parse(input);
|
||||
result.Headers["X-Tag"].ShouldBe(["a", "b"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_invalid_returns_defaults()
|
||||
{
|
||||
var input = "GARBAGE\r\n\r\n"u8;
|
||||
var result = NatsHeaderParser.Parse(input);
|
||||
result.Status.ShouldBe(-1);
|
||||
}
|
||||
}
|
||||
54
tests/NATS.Server.Core.Tests/NatsOptionsTests.cs
Normal file
54
tests/NATS.Server.Core.Tests/NatsOptionsTests.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class NatsOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_are_correct()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.MaxSubs.ShouldBe(0);
|
||||
opts.MaxSubTokens.ShouldBe(0);
|
||||
opts.Debug.ShouldBe(false);
|
||||
opts.Trace.ShouldBe(false);
|
||||
opts.LogFile.ShouldBeNull();
|
||||
opts.LogSizeLimit.ShouldBe(0L);
|
||||
opts.Tags.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_fields_have_correct_defaults()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.ClientAdvertise.ShouldBeNull();
|
||||
opts.TraceVerbose.ShouldBeFalse();
|
||||
opts.MaxTracedMsgLen.ShouldBe(0);
|
||||
opts.DisableSublistCache.ShouldBeFalse();
|
||||
opts.ConnectErrorReports.ShouldBe(3600);
|
||||
opts.ReconnectErrorReports.ShouldBe(1);
|
||||
opts.NoHeaderSupport.ShouldBeFalse();
|
||||
opts.MaxClosedClients.ShouldBe(10_000);
|
||||
opts.NoSystemAccount.ShouldBeFalse();
|
||||
opts.SystemAccount.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
public class LogOverrideTests
|
||||
{
|
||||
[Fact]
|
||||
public void LogOverrides_defaults_to_null()
|
||||
{
|
||||
var options = new NatsOptions();
|
||||
options.LogOverrides.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogOverrides_can_be_set()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
LogOverrides = new() { ["NATS.Server.Protocol"] = "Trace" }
|
||||
};
|
||||
options.LogOverrides.ShouldNotBeNull();
|
||||
options.LogOverrides["NATS.Server.Protocol"].ShouldBe("Trace");
|
||||
}
|
||||
}
|
||||
99
tests/NATS.Server.Core.Tests/NoRespondersTests.cs
Normal file
99
tests/NATS.Server.Core.Tests/NoRespondersTests.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class NoRespondersTests : IAsyncLifetime
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public NoRespondersTests()
|
||||
{
|
||||
_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<Socket> ConnectClientAsync()
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, _port);
|
||||
return sock;
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task NoResponders_without_headers_closes_connection()
|
||||
{
|
||||
using var client = await ConnectClientAsync();
|
||||
|
||||
// Read INFO
|
||||
await SocketTestHelper.ReadUntilAsync(client, "\r\n");
|
||||
|
||||
// Send CONNECT with no_responders:true but headers:false
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes(
|
||||
"CONNECT {\"no_responders\":true,\"headers\":false}\r\n"));
|
||||
|
||||
// Should receive -ERR and connection should close
|
||||
var response = await SocketTestHelper.ReadUntilAsync(client, "-ERR");
|
||||
response.ShouldContain("-ERR");
|
||||
response.ShouldContain("No Responders Requires Headers Support");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoResponders_with_headers_accepted()
|
||||
{
|
||||
using var client = await ConnectClientAsync();
|
||||
|
||||
// Read INFO
|
||||
await SocketTestHelper.ReadUntilAsync(client, "\r\n");
|
||||
|
||||
// Send CONNECT with both no_responders and headers true, then PING
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes(
|
||||
"CONNECT {\"no_responders\":true,\"headers\":true}\r\nPING\r\n"));
|
||||
|
||||
// Should receive PONG (connection stays alive)
|
||||
var response = await SocketTestHelper.ReadUntilAsync(client, "PONG\r\n");
|
||||
response.ShouldContain("PONG\r\n");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoResponders_sends_503_when_no_subscribers()
|
||||
{
|
||||
using var client = await ConnectClientAsync();
|
||||
|
||||
// Read INFO
|
||||
await SocketTestHelper.ReadUntilAsync(client, "\r\n");
|
||||
|
||||
// CONNECT with no_responders and headers enabled
|
||||
// SUB to the reply inbox so we can receive the 503
|
||||
// PUB to a subject with no subscribers, using a reply-to subject
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes(
|
||||
"CONNECT {\"no_responders\":true,\"headers\":true}\r\n" +
|
||||
"SUB _INBOX.reply 1\r\n" +
|
||||
"PUB no.subscribers _INBOX.reply 5\r\nHello\r\n"));
|
||||
|
||||
// Should receive HMSG with 503 status on the reply subject
|
||||
var response = await SocketTestHelper.ReadUntilAsync(client, "HMSG");
|
||||
response.ShouldContain("HMSG _INBOX.reply 1");
|
||||
response.ShouldContain("503");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using NATS.Server.TestUtilities.Parity;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Parity;
|
||||
|
||||
public class JetStreamParityTruthMatrixTests
|
||||
{
|
||||
[Fact]
|
||||
public void Jetstream_parity_rows_require_behavior_test_and_docs_alignment()
|
||||
{
|
||||
var report = JetStreamParityTruthMatrix.Load(
|
||||
"differences.md",
|
||||
"docs/plans/2026-02-23-jetstream-remaining-parity-map.md");
|
||||
|
||||
report.DriftRows.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jetstream_differences_notes_have_no_contradictions_against_status_table_and_truth_matrix()
|
||||
{
|
||||
var report = JetStreamParityTruthMatrix.Load(
|
||||
"differences.md",
|
||||
"docs/plans/2026-02-23-jetstream-remaining-parity-map.md");
|
||||
|
||||
report.Contradictions.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using NATS.Server.TestUtilities.Parity;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Parity;
|
||||
|
||||
public class NatsStrictCapabilityInventoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Strict_capability_inventory_has_no_open_items_marked_done_without_behavior_and_tests()
|
||||
{
|
||||
var report = NatsCapabilityInventory.Load(
|
||||
"docs/plans/2026-02-23-nats-strict-full-go-parity-map.md");
|
||||
report.InvalidRows.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
289
tests/NATS.Server.Core.Tests/ParserTests.cs
Normal file
289
tests/NATS.Server.Core.Tests/ParserTests.cs
Normal file
@@ -0,0 +1,289 @@
|
||||
using System.Buffers;
|
||||
using System.IO.Pipelines;
|
||||
using System.Text;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ParserTests
|
||||
{
|
||||
private static async Task<List<ParsedCommand>> ParseAsync(string input)
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
var commands = new List<ParsedCommand>();
|
||||
|
||||
// Write input to pipe
|
||||
var bytes = Encoding.ASCII.GetBytes(input);
|
||||
await pipe.Writer.WriteAsync(bytes);
|
||||
pipe.Writer.Complete();
|
||||
|
||||
// Parse from pipe
|
||||
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;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_PING()
|
||||
{
|
||||
var cmds = await ParseAsync("PING\r\n");
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Ping);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_PONG()
|
||||
{
|
||||
var cmds = await ParseAsync("PONG\r\n");
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Pong);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_CONNECT()
|
||||
{
|
||||
var cmds = await ParseAsync("CONNECT {\"verbose\":false,\"echo\":true}\r\n");
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Connect);
|
||||
Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldContain("verbose");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_SUB_without_queue()
|
||||
{
|
||||
var cmds = await ParseAsync("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");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_SUB_with_queue()
|
||||
{
|
||||
var cmds = await ParseAsync("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");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_UNSUB()
|
||||
{
|
||||
var cmds = await ParseAsync("UNSUB 1\r\n");
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Unsub);
|
||||
cmds[0].Sid.ShouldBe("1");
|
||||
cmds[0].MaxMessages.ShouldBe(-1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_UNSUB_with_max()
|
||||
{
|
||||
var cmds = await ParseAsync("UNSUB 1 5\r\n");
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Unsub);
|
||||
cmds[0].Sid.ShouldBe("1");
|
||||
cmds[0].MaxMessages.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_PUB_with_payload()
|
||||
{
|
||||
var cmds = await ParseAsync("PUB foo 5\r\nHello\r\n");
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Pub);
|
||||
cmds[0].Subject.ShouldBe("foo");
|
||||
cmds[0].ReplyTo.ShouldBeNull();
|
||||
Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldBe("Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_PUB_with_reply()
|
||||
{
|
||||
var cmds = await ParseAsync("PUB foo reply 5\r\nHello\r\n");
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Pub);
|
||||
cmds[0].Subject.ShouldBe("foo");
|
||||
cmds[0].ReplyTo.ShouldBe("reply");
|
||||
Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldBe("Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_multiple_commands()
|
||||
{
|
||||
var cmds = await ParseAsync("PING\r\nPONG\r\nSUB foo 1\r\n");
|
||||
cmds.Count.ShouldBe(3);
|
||||
cmds[0].Type.ShouldBe(CommandType.Ping);
|
||||
cmds[1].Type.ShouldBe(CommandType.Pong);
|
||||
cmds[2].Type.ShouldBe(CommandType.Sub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_PUB_zero_payload()
|
||||
{
|
||||
var cmds = await ParseAsync("PUB foo 0\r\n\r\n");
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Pub);
|
||||
cmds[0].Payload.ToArray().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_case_insensitive()
|
||||
{
|
||||
var cmds = await ParseAsync("ping\r\npub FOO 3\r\nabc\r\n");
|
||||
cmds.Count.ShouldBe(2);
|
||||
cmds[0].Type.ShouldBe(CommandType.Ping);
|
||||
cmds[1].Type.ShouldBe(CommandType.Pub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_HPUB()
|
||||
{
|
||||
// HPUB subject 12 17\r\nNATS/1.0\r\n\r\nHello\r\n
|
||||
var header = "NATS/1.0\r\n\r\n";
|
||||
var payload = "Hello";
|
||||
var total = header.Length + payload.Length;
|
||||
var cmds = await ParseAsync($"HPUB foo {header.Length} {total}\r\n{header}{payload}\r\n");
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.HPub);
|
||||
cmds[0].Subject.ShouldBe("foo");
|
||||
cmds[0].HeaderSize.ShouldBe(header.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_INFO()
|
||||
{
|
||||
var cmds = await ParseAsync("INFO {\"server_id\":\"test\"}\r\n");
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Info);
|
||||
}
|
||||
|
||||
// Mirrors Go TestParsePubArg: verifies subject, optional reply, and payload size
|
||||
// are parsed correctly across various combinations of spaces and tabs.
|
||||
// Reference: golang/nats-server/server/parser_test.go TestParsePubArg
|
||||
[Theory]
|
||||
[InlineData("PUB a 2\r\nok\r\n", "a", null, "ok")]
|
||||
[InlineData("PUB foo 2\r\nok\r\n", "foo", null, "ok")]
|
||||
[InlineData("PUB foo 2\r\nok\r\n", "foo", null, "ok")]
|
||||
[InlineData("PUB foo 2\r\nok\r\n", "foo", null, "ok")]
|
||||
[InlineData("PUB foo 2\r\nok\r\n", "foo", null, "ok")]
|
||||
[InlineData("PUB foo bar 2\r\nok\r\n", "foo", "bar", "ok")]
|
||||
[InlineData("PUB foo bar 2\r\nok\r\n", "foo", "bar", "ok")]
|
||||
[InlineData("PUB foo bar 2\r\nok\r\n", "foo", "bar", "ok")]
|
||||
[InlineData("PUB foo bar 2 \r\nok\r\n", "foo", "bar", "ok")]
|
||||
[InlineData("PUB a\t2\r\nok\r\n", "a", null, "ok")]
|
||||
[InlineData("PUB foo\t2\r\nok\r\n", "foo", null, "ok")]
|
||||
[InlineData("PUB \tfoo\t2\r\nok\r\n", "foo", null, "ok")]
|
||||
[InlineData("PUB foo\t\t\t2\r\nok\r\n", "foo", null, "ok")]
|
||||
[InlineData("PUB foo\tbar\t2\r\nok\r\n", "foo", "bar", "ok")]
|
||||
[InlineData("PUB foo\t\tbar\t\t2\r\nok\r\n","foo", "bar", "ok")]
|
||||
public async Task Parse_PUB_argument_variations(
|
||||
string input, string expectedSubject, string? expectedReply, string expectedPayload)
|
||||
{
|
||||
var cmds = await ParseAsync(input);
|
||||
cmds.ShouldHaveSingleItem();
|
||||
cmds[0].Type.ShouldBe(CommandType.Pub);
|
||||
cmds[0].Subject.ShouldBe(expectedSubject);
|
||||
cmds[0].ReplyTo.ShouldBe(expectedReply);
|
||||
Encoding.ASCII.GetString(cmds[0].Payload.ToArray()).ShouldBe(expectedPayload);
|
||||
}
|
||||
|
||||
// Helper that parses a protocol string and expects a ProtocolViolationException to be thrown.
|
||||
private static async Task<Exception> ParseExpectingErrorAsync(string input)
|
||||
{
|
||||
var pipe = new Pipe();
|
||||
var bytes = Encoding.ASCII.GetBytes(input);
|
||||
await pipe.Writer.WriteAsync(bytes);
|
||||
pipe.Writer.Complete();
|
||||
|
||||
var parser = new NatsParser(maxPayload: NatsProtocol.MaxPayloadSize);
|
||||
Exception? caught = null;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var result = await pipe.Reader.ReadAsync();
|
||||
var buffer = result.Buffer;
|
||||
|
||||
while (parser.TryParse(ref buffer, out _))
|
||||
{
|
||||
// consume successfully parsed commands
|
||||
}
|
||||
|
||||
pipe.Reader.AdvanceTo(buffer.Start, buffer.End);
|
||||
|
||||
if (result.IsCompleted)
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
caught = ex;
|
||||
}
|
||||
|
||||
caught.ShouldNotBeNull("Expected a ProtocolViolationException but no exception was thrown.");
|
||||
return caught!;
|
||||
}
|
||||
|
||||
// Mirrors Go TestShouldFail: malformed protocol inputs that the parser must reject.
|
||||
// The .NET parser signals errors by throwing ProtocolViolationException.
|
||||
// Note: "PIx", "PINx" and "UNSUB_2" are not included here because the .NET parser
|
||||
// uses 2-byte prefix matching (b0+b1) rather than Go's byte-by-byte state machine.
|
||||
// As a result, "PIx" matches "PI"→PING and is silently accepted, and "UNSUB_2"
|
||||
// parses as UNSUB with sid "_2" — these are intentional behavioral differences.
|
||||
// Reference: golang/nats-server/server/parser_test.go TestShouldFail
|
||||
[Theory]
|
||||
[InlineData("Px\r\n")]
|
||||
[InlineData(" PING\r\n")]
|
||||
[InlineData("SUB\r\n")]
|
||||
[InlineData("SUB \r\n")]
|
||||
[InlineData("SUB foo\r\n")]
|
||||
[InlineData("PUB foo\r\n")]
|
||||
[InlineData("PUB \r\n")]
|
||||
[InlineData("PUB foo bar \r\n")]
|
||||
public async Task Parse_malformed_protocol_fails(string input)
|
||||
{
|
||||
var ex = await ParseExpectingErrorAsync(input);
|
||||
ex.ShouldBeOfType<ProtocolViolationException>();
|
||||
}
|
||||
|
||||
// Mirrors Go TestMaxControlLine: a control line exceeding 4096 bytes must be rejected.
|
||||
// Reference: golang/nats-server/server/parser_test.go TestMaxControlLine
|
||||
[Fact]
|
||||
public async Task Parse_exceeding_max_control_line_fails()
|
||||
{
|
||||
// Build a PUB command whose control line (subject + size field) exceeds 4096 bytes.
|
||||
var longSubject = new string('a', NatsProtocol.MaxControlLineSize);
|
||||
var input = $"PUB {longSubject} 0\r\n\r\n";
|
||||
var ex = await ParseExpectingErrorAsync(input);
|
||||
ex.ShouldBeOfType<ProtocolViolationException>();
|
||||
}
|
||||
|
||||
// Mirrors Go TestParsePubSizeOverflow: oversized decimal payload lengths
|
||||
// must be rejected during PUB argument parsing.
|
||||
// Reference: golang/nats-server/server/parser_test.go TestParsePubSizeOverflow
|
||||
[Fact]
|
||||
public async Task Parse_pub_size_overflow_fails()
|
||||
{
|
||||
var ex = await ParseExpectingErrorAsync("PUB foo 1234567890\r\n");
|
||||
ex.ShouldBeOfType<ProtocolViolationException>();
|
||||
ex.Message.ShouldContain("Invalid payload size");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,860 @@
|
||||
// Go reference: golang/nats-server/server/client_test.go
|
||||
// Ports specific Go tests that map to existing .NET features:
|
||||
// header stripping, subject/queue parsing, wildcard handling,
|
||||
// message tracing, connection limits, header manipulation,
|
||||
// message parts, and NRG subject rejection.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Subscriptions;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Go parity tests ported from client_test.go for protocol-level behaviors
|
||||
/// covering header stripping, subject/queue parsing, wildcard handling,
|
||||
/// tracing, connection limits, header manipulation, and NRG subjects.
|
||||
/// </summary>
|
||||
public class ClientProtocolGoParityTests
|
||||
{
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers (self-contained per project conventions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private static async Task<string> ReadAllAvailableAsync(Socket sock, int timeoutMs = 1000)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
var sb = new StringBuilder();
|
||||
var buf = new byte[8192];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||||
if (n == 0) break;
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static async Task<(NatsServer Server, int Port, CancellationTokenSource Cts)>
|
||||
StartServerAsync(NatsOptions? options = null)
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options ??= new NatsOptions();
|
||||
options.Port = port;
|
||||
var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, port, cts);
|
||||
}
|
||||
|
||||
private static async Task<Socket> ConnectAndHandshakeAsync(int port, string connectJson = "{}")
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||||
await SocketTestHelper.ReadUntilAsync(sock, "\r\n"); // drain INFO
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"CONNECT {connectJson}\r\n"));
|
||||
return sock;
|
||||
}
|
||||
|
||||
private static async Task<Socket> ConnectAndPingAsync(int port, string connectJson = "{}")
|
||||
{
|
||||
var sock = await ConnectAndHandshakeAsync(port, connectJson);
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n");
|
||||
return sock;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestClientHeaderDeliverStrippedMsg — client_test.go:373
|
||||
// When a subscriber does NOT support headers (no headers:true in CONNECT),
|
||||
// the server must strip headers and deliver a plain MSG with only the payload.
|
||||
// =========================================================================
|
||||
|
||||
[Fact(Skip = "Header stripping for non-header-aware subscribers not yet implemented in .NET server")]
|
||||
public async Task Header_stripped_for_non_header_subscriber()
|
||||
{
|
||||
// Go: TestClientHeaderDeliverStrippedMsg client_test.go:373
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
// Subscriber does NOT advertise headers:true
|
||||
using var sub = await ConnectAndPingAsync(port, "{}");
|
||||
// Publisher DOES advertise headers:true
|
||||
using var pub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
// HPUB foo 12 14\r\nName:Derek\r\nOK\r\n
|
||||
// Header block: "Name:Derek\r\n" = 12 bytes
|
||||
// Payload: "OK" = 2 bytes -> total = 14
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("HPUB foo 12 14\r\nName:Derek\r\nOK\r\n"));
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG\r\n");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
// Non-header subscriber should get a plain MSG with only the payload (2 bytes: "OK")
|
||||
response.ShouldContain("MSG foo 1 2\r\n");
|
||||
response.ShouldContain("OK\r\n");
|
||||
// Should NOT get HMSG
|
||||
response.ShouldNotContain("HMSG");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestClientHeaderDeliverQueueSubStrippedMsg — client_test.go:421
|
||||
// Same as above but with a queue subscription.
|
||||
// =========================================================================
|
||||
|
||||
[Fact(Skip = "Header stripping for non-header-aware subscribers not yet implemented in .NET server")]
|
||||
public async Task Header_stripped_for_non_header_queue_subscriber()
|
||||
{
|
||||
// Go: TestClientHeaderDeliverQueueSubStrippedMsg client_test.go:421
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
// Queue subscriber does NOT advertise headers:true
|
||||
using var sub = await ConnectAndPingAsync(port, "{}");
|
||||
using var pub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
|
||||
// Queue subscription: SUB foo bar 1
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo bar 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("HPUB foo 12 14\r\nName:Derek\r\nOK\r\n"));
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG\r\n");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
// Queue subscriber without headers should get MSG with only payload
|
||||
response.ShouldContain("MSG foo 1 2\r\n");
|
||||
response.ShouldContain("OK\r\n");
|
||||
response.ShouldNotContain("HMSG");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSplitSubjectQueue — client_test.go:811
|
||||
// Tests parsing of subject/queue from "SUB subject [queue] sid" arguments.
|
||||
// This tests SubjectMatch utilities rather than the parser directly.
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo", "foo", null, false)]
|
||||
[InlineData("foo bar", "foo", "bar", false)]
|
||||
[InlineData("foo bar", "foo", "bar", false)]
|
||||
public void SplitSubjectQueue_parses_correctly(string input, string expectedSubject, string? expectedQueue, bool expectError)
|
||||
{
|
||||
// Go: TestSplitSubjectQueue client_test.go:811
|
||||
// The Go test uses splitSubjectQueue which parses the SUB argument line.
|
||||
// In .NET, we validate the same concept via subject parsing logic.
|
||||
var parts = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (expectError)
|
||||
{
|
||||
parts.Length.ShouldBeGreaterThan(2);
|
||||
return;
|
||||
}
|
||||
|
||||
parts[0].ShouldBe(expectedSubject);
|
||||
if (expectedQueue is not null)
|
||||
{
|
||||
parts.Length.ShouldBeGreaterThanOrEqualTo(2);
|
||||
parts[1].ShouldBe(expectedQueue);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitSubjectQueue_extra_tokens_error()
|
||||
{
|
||||
// Go: TestSplitSubjectQueue client_test.go:828 — "foo bar fizz" should error
|
||||
var parts = "foo bar fizz".Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
parts.Length.ShouldBe(3); // three tokens is too many for subject+queue
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestWildcardCharsInLiteralSubjectWorks — client_test.go:1444
|
||||
// Subjects containing * and > that are NOT at token boundaries are treated
|
||||
// as literal characters, not wildcards.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Wildcard_chars_in_literal_subject_work()
|
||||
{
|
||||
// Go: TestWildcardCharsInLiteralSubjectWorks client_test.go:1444
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
using var sock = await ConnectAndPingAsync(port);
|
||||
|
||||
// "foo.bar,*,>,baz" contains *, > but they're NOT at token boundaries
|
||||
// (they're embedded in a comma-delimited token), so they are literal
|
||||
var subj = "foo.bar,*,>,baz";
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"SUB {subj} 1\r\nPUB {subj} 3\r\nmsg\r\nPING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n");
|
||||
|
||||
response.ShouldContain($"MSG {subj} 1 3\r\n");
|
||||
response.ShouldContain("msg\r\n");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestTraceMsg — client_test.go:1700
|
||||
// Tests that trace message formatting truncates correctly.
|
||||
// (Unit test on the traceMsg formatting logic)
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("normal", 10, "normal")]
|
||||
[InlineData("over length", 10, "over lengt")]
|
||||
[InlineData("unlimited length", 0, "unlimited length")]
|
||||
public void TraceMsg_truncation_logic(string msg, int maxLen, string expectedPrefix)
|
||||
{
|
||||
// Go: TestTraceMsg client_test.go:1700
|
||||
// Verifying the truncation logic that would be applied when tracing messages.
|
||||
// In Go: if maxTracedMsgLen > 0 && len(msg) > maxTracedMsgLen, truncate + "..."
|
||||
string result;
|
||||
if (maxLen > 0 && msg.Length > maxLen)
|
||||
result = msg[..maxLen] + "...";
|
||||
else
|
||||
result = msg;
|
||||
|
||||
result.ShouldStartWith(expectedPrefix);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestTraceMsgHeadersOnly — client_test.go:1753
|
||||
// When trace_headers mode is on, only the header portion is traced,
|
||||
// not the payload. Tests the header extraction logic.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void TraceMsgHeadersOnly_extracts_header_portion()
|
||||
{
|
||||
// Go: TestTraceMsgHeadersOnly client_test.go:1753
|
||||
// The Go test verifies that when TraceHeaders is true, only the header
|
||||
// portion up to the terminal \r\n\r\n is traced.
|
||||
var hdr = "NATS/1.0\r\nFoo: 1\r\n\r\n";
|
||||
var payload = "test\r\n";
|
||||
var full = hdr + payload;
|
||||
|
||||
// Extract header portion (everything before the terminal \r\n\r\n)
|
||||
var hdrEnd = full.IndexOf("\r\n\r\n", StringComparison.Ordinal);
|
||||
hdrEnd.ShouldBeGreaterThan(0);
|
||||
|
||||
var headerOnly = full[..hdrEnd];
|
||||
// Replace actual \r\n with escaped for display, matching Go behavior
|
||||
var escaped = headerOnly.Replace("\r\n", "\\r\\n");
|
||||
escaped.ShouldContain("NATS/1.0");
|
||||
escaped.ShouldContain("Foo: 1");
|
||||
escaped.ShouldNotContain("test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TraceMsgHeadersOnly_two_headers_with_max_length()
|
||||
{
|
||||
// Go: TestTraceMsgHeadersOnly client_test.go:1797 — two headers max length
|
||||
var hdr = "NATS/1.0\r\nFoo: 1\r\nBar: 2\r\n\r\n";
|
||||
var hdrEnd = hdr.IndexOf("\r\n\r\n", StringComparison.Ordinal);
|
||||
var headerOnly = hdr[..hdrEnd];
|
||||
var escaped = headerOnly.Replace("\r\n", "\\r\\n");
|
||||
|
||||
// With maxLen=21, should truncate: "NATS/1.0\r\nFoo: 1\r\nBar..."
|
||||
const int maxLen = 21;
|
||||
string result;
|
||||
if (escaped.Length > maxLen)
|
||||
result = escaped[..maxLen] + "...";
|
||||
else
|
||||
result = escaped;
|
||||
|
||||
result.ShouldContain("NATS/1.0");
|
||||
result.ShouldContain("Foo: 1");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestTraceMsgDelivery — client_test.go:1821
|
||||
// End-to-end test: with tracing enabled, messages flow correctly between
|
||||
// publisher and subscriber (the tracing must not break delivery).
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Trace_mode_does_not_break_message_delivery()
|
||||
{
|
||||
// Go: TestTraceMsgDelivery client_test.go:1821
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
using var sub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
using var pub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
// Publish a message with headers
|
||||
var hdr = "NATS/1.0\r\nA: 1\r\nB: 2\r\n\r\n";
|
||||
var payload = "Hello Traced";
|
||||
var totalLen = hdr.Length + payload.Length;
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(
|
||||
$"HPUB foo {hdr.Length} {totalLen}\r\n{hdr}{payload}\r\n"));
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG\r\n");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
response.ShouldContain("HMSG foo 1");
|
||||
response.ShouldContain("Hello Traced");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestTraceMsgDeliveryWithHeaders — client_test.go:1886
|
||||
// Similar to above but specifically validates headers are present in delivery.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Trace_delivery_preserves_headers()
|
||||
{
|
||||
// Go: TestTraceMsgDeliveryWithHeaders client_test.go:1886
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
using var sub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
using var pub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
var hdr = "NATS/1.0\r\nFoo: bar\r\nBaz: qux\r\n\r\n";
|
||||
var payload = "data";
|
||||
var totalLen = hdr.Length + payload.Length;
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(
|
||||
$"HPUB foo {hdr.Length} {totalLen}\r\n{hdr}{payload}\r\n"));
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG\r\n");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
response.ShouldContain("HMSG foo 1");
|
||||
response.ShouldContain("NATS/1.0");
|
||||
response.ShouldContain("Foo: bar");
|
||||
response.ShouldContain("Baz: qux");
|
||||
response.ShouldContain("data");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestClientLimits — client_test.go:2583
|
||||
// Tests the min-of-three logic: client JWT limit, account limit, server limit.
|
||||
// The effective limit should be the smallest positive value.
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 1, 1, 1)]
|
||||
[InlineData(-1, -1, 0, -1)]
|
||||
[InlineData(1, -1, 0, 1)]
|
||||
[InlineData(-1, 1, 0, 1)]
|
||||
[InlineData(-1, -1, 1, 1)]
|
||||
[InlineData(1, 2, 3, 1)]
|
||||
[InlineData(2, 1, 3, 1)]
|
||||
[InlineData(3, 2, 1, 1)]
|
||||
public void Client_limits_picks_smallest_positive(int client, int acc, int srv, int expected)
|
||||
{
|
||||
// Go: TestClientLimits client_test.go:2583
|
||||
// The effective limit is the smallest positive value among client, account, server.
|
||||
// -1 or 0 means unlimited for that level.
|
||||
var values = new[] { client, acc, srv }.Where(v => v > 0).ToArray();
|
||||
int result = values.Length > 0 ? values.Min() : (client == -1 && acc == -1 ? -1 : 0);
|
||||
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestClientClampMaxSubsErrReport — client_test.go:2645
|
||||
// When max subs is exceeded, the server logs an error. Verify the server
|
||||
// enforces the max subs limit at the protocol level.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task MaxSubs_exceeded_returns_error()
|
||||
{
|
||||
// Go: TestClientClampMaxSubsErrReport client_test.go:2645
|
||||
var (server, port, cts) = await StartServerAsync(new NatsOptions { MaxSubs = 1 });
|
||||
try
|
||||
{
|
||||
using var sock = await ConnectAndPingAsync(port);
|
||||
|
||||
// First sub should succeed
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nPING\r\n"));
|
||||
var r1 = await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n");
|
||||
r1.ShouldNotContain("-ERR");
|
||||
|
||||
// Second sub should exceed the limit
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("SUB bar 2\r\n"));
|
||||
var r2 = await ReadAllAvailableAsync(sock, 3000);
|
||||
r2.ShouldContain("-ERR 'Maximum Subscriptions Exceeded'");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestRemoveHeaderIfPrefixPresent — client_test.go:3158
|
||||
// Tests removal of headers with a given prefix from NATS header block.
|
||||
// This validates the NatsHeaderParser's ability to parse and the concept
|
||||
// of header prefix filtering.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void RemoveHeaderIfPrefixPresent_strips_matching_headers()
|
||||
{
|
||||
// Go: TestRemoveHeaderIfPrefixPresent client_test.go:3158
|
||||
// Build a header block with mixed headers, some with "Nats-Expected-" prefix
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("a: 1\r\n");
|
||||
sb.Append("Nats-Expected-Stream: my-stream\r\n");
|
||||
sb.Append("Nats-Expected-Last-Sequence: 22\r\n");
|
||||
sb.Append("b: 2\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence: 24\r\n");
|
||||
sb.Append("Nats-Expected-Last-Msg-Id: 1\r\n");
|
||||
sb.Append("c: 3\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
|
||||
// After removing headers with prefix "Nats-Expected-", only a, b, c should remain
|
||||
var remaining = headers.Headers
|
||||
.Where(kv => !kv.Key.StartsWith("Nats-Expected-", StringComparison.OrdinalIgnoreCase))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
remaining.ContainsKey("a").ShouldBeTrue();
|
||||
remaining["a"].ShouldBe(["1"]);
|
||||
remaining.ContainsKey("b").ShouldBeTrue();
|
||||
remaining["b"].ShouldBe(["2"]);
|
||||
remaining.ContainsKey("c").ShouldBeTrue();
|
||||
remaining["c"].ShouldBe(["3"]);
|
||||
remaining.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSliceHeader — client_test.go:3176
|
||||
// Tests extracting a specific header value from a NATS header block.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SliceHeader_extracts_specific_header_value()
|
||||
{
|
||||
// Go: TestSliceHeader client_test.go:3176
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("a: 1\r\n");
|
||||
sb.Append("Nats-Expected-Stream: my-stream\r\n");
|
||||
sb.Append("Nats-Expected-Last-Sequence: 22\r\n");
|
||||
sb.Append("b: 2\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence: 24\r\n");
|
||||
sb.Append("Nats-Expected-Last-Msg-Id: 1\r\n");
|
||||
sb.Append("c: 3\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
headers.Headers.TryGetValue("Nats-Expected-Last-Subject-Sequence", out var values).ShouldBeTrue();
|
||||
values!.ShouldBe(["24"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSliceHeaderOrderingPrefix — client_test.go:3199
|
||||
// Headers sharing a prefix must not confuse the parser.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SliceHeader_prefix_ordering_does_not_confuse_parser()
|
||||
{
|
||||
// Go: TestSliceHeaderOrderingPrefix client_test.go:3199
|
||||
// "Nats-Expected-Last-Subject-Sequence-Subject" shares prefix with
|
||||
// "Nats-Expected-Last-Subject-Sequence" — parser must distinguish them.
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence-Subject: foo\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence: 24\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
headers.Headers.TryGetValue("Nats-Expected-Last-Subject-Sequence", out var values).ShouldBeTrue();
|
||||
values!.ShouldBe(["24"]);
|
||||
headers.Headers.TryGetValue("Nats-Expected-Last-Subject-Sequence-Subject", out var subjValues).ShouldBeTrue();
|
||||
subjValues!.ShouldBe(["foo"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSliceHeaderOrderingSuffix — client_test.go:3219
|
||||
// Headers sharing a suffix must not confuse the parser.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SliceHeader_suffix_ordering_does_not_confuse_parser()
|
||||
{
|
||||
// Go: TestSliceHeaderOrderingSuffix client_test.go:3219
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("Previous-Nats-Msg-Id: user\r\n");
|
||||
sb.Append("Nats-Msg-Id: control\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
headers.Headers.TryGetValue("Nats-Msg-Id", out var msgId).ShouldBeTrue();
|
||||
msgId!.ShouldBe(["control"]);
|
||||
headers.Headers.TryGetValue("Previous-Nats-Msg-Id", out var prevId).ShouldBeTrue();
|
||||
prevId!.ShouldBe(["user"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestRemoveHeaderIfPresentOrderingPrefix — client_test.go:3236
|
||||
// Removing a header that shares a prefix with another must not remove both.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void RemoveHeader_prefix_ordering_removes_only_exact_match()
|
||||
{
|
||||
// Go: TestRemoveHeaderIfPresentOrderingPrefix client_test.go:3236
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence-Subject: foo\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence: 24\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
var remaining = headers.Headers
|
||||
.Where(kv => !string.Equals(kv.Key, "Nats-Expected-Last-Subject-Sequence", StringComparison.OrdinalIgnoreCase))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
remaining.Count.ShouldBe(1);
|
||||
remaining.ContainsKey("Nats-Expected-Last-Subject-Sequence-Subject").ShouldBeTrue();
|
||||
remaining["Nats-Expected-Last-Subject-Sequence-Subject"].ShouldBe(["foo"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestRemoveHeaderIfPresentOrderingSuffix — client_test.go:3249
|
||||
// Removing a header that shares a suffix with another must not remove both.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void RemoveHeader_suffix_ordering_removes_only_exact_match()
|
||||
{
|
||||
// Go: TestRemoveHeaderIfPresentOrderingSuffix client_test.go:3249
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("Previous-Nats-Msg-Id: user\r\n");
|
||||
sb.Append("Nats-Msg-Id: control\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
var remaining = headers.Headers
|
||||
.Where(kv => !string.Equals(kv.Key, "Nats-Msg-Id", StringComparison.OrdinalIgnoreCase))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
remaining.Count.ShouldBe(1);
|
||||
remaining.ContainsKey("Previous-Nats-Msg-Id").ShouldBeTrue();
|
||||
remaining["Previous-Nats-Msg-Id"].ShouldBe(["user"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSetHeaderDoesNotOverwriteUnderlyingBuffer — client_test.go:3283
|
||||
// Setting a header value must not corrupt the message body.
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("Key1", "Val1Updated", "NATS/1.0\r\nKey1: Val1Updated\r\nKey2: Val2\r\n\r\n")]
|
||||
[InlineData("Key1", "v1", "NATS/1.0\r\nKey1: v1\r\nKey2: Val2\r\n\r\n")]
|
||||
[InlineData("Key3", "Val3", "NATS/1.0\r\nKey1: Val1\r\nKey2: Val2\r\nKey3: Val3\r\n\r\n")]
|
||||
public void SetHeader_does_not_overwrite_underlying_buffer(string key, string value, string expectedHdr)
|
||||
{
|
||||
// Go: TestSetHeaderDoesNotOverwriteUnderlyingBuffer client_test.go:3283
|
||||
var initialHdr = "NATS/1.0\r\nKey1: Val1\r\nKey2: Val2\r\n\r\n";
|
||||
var msgBody = "this is the message body\r\n";
|
||||
|
||||
// Parse the initial header
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(initialHdr));
|
||||
|
||||
// Modify the header
|
||||
var mutableHeaders = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kv in headers.Headers)
|
||||
mutableHeaders[kv.Key] = [.. kv.Value];
|
||||
|
||||
if (mutableHeaders.ContainsKey(key))
|
||||
mutableHeaders[key] = [value];
|
||||
else
|
||||
mutableHeaders[key] = [value];
|
||||
|
||||
// Rebuild header block
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
foreach (var kv in mutableHeaders.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var v in kv.Value)
|
||||
sb.Append($"{kv.Key}: {v}\r\n");
|
||||
}
|
||||
sb.Append("\r\n");
|
||||
|
||||
var rebuiltHdr = sb.ToString();
|
||||
|
||||
// Parse the expected header to verify structure
|
||||
var expectedParsed = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(expectedHdr));
|
||||
var rebuiltParsed = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(rebuiltHdr));
|
||||
|
||||
rebuiltParsed.Headers[key].ShouldBe([value]);
|
||||
// The message body should not be affected
|
||||
msgBody.ShouldBe("this is the message body\r\n");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSetHeaderOrderingPrefix — client_test.go:3321
|
||||
// Setting a header that shares a prefix with another must update the correct one.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SetHeader_prefix_ordering_updates_correct_header()
|
||||
{
|
||||
// Go: TestSetHeaderOrderingPrefix client_test.go:3321
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence-Subject: foo\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence: 24\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
|
||||
// Verify the shorter-named header has correct value
|
||||
headers.Headers.TryGetValue("Nats-Expected-Last-Subject-Sequence", out var values).ShouldBeTrue();
|
||||
values!.ShouldBe(["24"]);
|
||||
|
||||
// The longer-named header should be unaffected
|
||||
headers.Headers.TryGetValue("Nats-Expected-Last-Subject-Sequence-Subject", out var subjValues).ShouldBeTrue();
|
||||
subjValues!.ShouldBe(["foo"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSetHeaderOrderingSuffix — client_test.go:3349
|
||||
// Setting a header that shares a suffix with another must update the correct one.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SetHeader_suffix_ordering_updates_correct_header()
|
||||
{
|
||||
// Go: TestSetHeaderOrderingSuffix client_test.go:3349
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("Previous-Nats-Msg-Id: user\r\n");
|
||||
sb.Append("Nats-Msg-Id: control\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
|
||||
headers.Headers.TryGetValue("Nats-Msg-Id", out var msgIdValues).ShouldBeTrue();
|
||||
msgIdValues!.ShouldBe(["control"]);
|
||||
headers.Headers.TryGetValue("Previous-Nats-Msg-Id", out var prevValues).ShouldBeTrue();
|
||||
prevValues!.ShouldBe(["user"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestMsgPartsCapsHdrSlice — client_test.go:3262
|
||||
// The header and message body parts must be independent slices;
|
||||
// appending to the header must not corrupt the body.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void MsgParts_header_and_body_independent()
|
||||
{
|
||||
// Go: TestMsgPartsCapsHdrSlice client_test.go:3262
|
||||
var hdrContent = "NATS/1.0\r\nKey1: Val1\r\nKey2: Val2\r\n\r\n";
|
||||
var msgBody = "hello\r\n";
|
||||
var combined = hdrContent + msgBody;
|
||||
|
||||
// Split into header and body
|
||||
var hdrEnd = combined.IndexOf("\r\n\r\n", StringComparison.Ordinal) + 4;
|
||||
var hdrPart = combined[..hdrEnd];
|
||||
var bodyPart = combined[hdrEnd..];
|
||||
|
||||
hdrPart.ShouldBe(hdrContent);
|
||||
bodyPart.ShouldBe(msgBody);
|
||||
|
||||
// Appending to hdrPart should not affect bodyPart
|
||||
var extendedHdr = hdrPart + "test";
|
||||
extendedHdr.ShouldBe(hdrContent + "test");
|
||||
bodyPart.ShouldBe("hello\r\n");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestClientRejectsNRGSubjects — client_test.go:3540
|
||||
// Non-system clients must be rejected when publishing to $NRG.* subjects.
|
||||
// =========================================================================
|
||||
|
||||
[Fact(Skip = "$NRG subject rejection for non-system clients not yet implemented in .NET server")]
|
||||
public async Task Client_rejects_NRG_subjects_for_non_system_users()
|
||||
{
|
||||
// Go: TestClientRejectsNRGSubjects client_test.go:3540
|
||||
// Normal (non-system) clients should get a permissions violation when
|
||||
// trying to publish to $NRG.* subjects.
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
using var sock = await ConnectAndPingAsync(port);
|
||||
|
||||
// Attempt to publish to an NRG subject
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("PUB $NRG.foo 0\r\n\r\nPING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n", timeoutMs: 5000);
|
||||
|
||||
// The server should reject this with a permissions violation
|
||||
// (In Go, non-system clients get a publish permission error for $NRG.*)
|
||||
response.ShouldContain("-ERR");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Additional header stripping tests — header subscriber gets HMSG
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Header_subscriber_receives_HMSG_with_full_headers()
|
||||
{
|
||||
// Go: TestClientHeaderDeliverMsg client_test.go:330
|
||||
// When the subscriber DOES support headers, it should get the full HMSG.
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
using var sub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
using var pub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("HPUB foo 12 14\r\nName:Derek\r\nOK\r\n"));
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG\r\n");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
// Header-aware subscriber should get HMSG with full headers
|
||||
response.ShouldContain("HMSG foo 1 12 14\r\n");
|
||||
response.ShouldContain("Name:Derek");
|
||||
response.ShouldContain("OK");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Wildcard in literal subject — second subscribe/unsubscribe cycle
|
||||
// Go: TestWildcardCharsInLiteralSubjectWorks client_test.go:1462
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Wildcard_chars_in_literal_subject_survive_unsub_resub()
|
||||
{
|
||||
// Go: TestWildcardCharsInLiteralSubjectWorks client_test.go:1462
|
||||
// The Go test does two iterations: subscribe, publish, receive, unsubscribe.
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
using var sock = await ConnectAndPingAsync(port);
|
||||
|
||||
var subj = "foo.bar,*,>,baz";
|
||||
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"SUB {subj} {i + 1}\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n");
|
||||
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"PUB {subj} 3\r\nmsg\r\nPING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n");
|
||||
response.ShouldContain($"MSG {subj} {i + 1} 3\r\n");
|
||||
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"UNSUB {i + 1}\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Priority group name regex validation
|
||||
// Go: TestPriorityGroupNameRegex consumer.go:49 — ^[a-zA-Z0-9/_=-]{1,16}$
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("A", true)]
|
||||
[InlineData("group/consumer=A", true)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("A B", false)]
|
||||
[InlineData("A\tB", false)]
|
||||
[InlineData("group-name-that-is-too-long", false)]
|
||||
[InlineData("\r\n", false)]
|
||||
public void PriorityGroupNameRegex_validates_correctly(string group, bool expected)
|
||||
{
|
||||
// Go: TestPriorityGroupNameRegex jetstream_consumer_test.go:2584
|
||||
// Go regex: ^[a-zA-Z0-9/_=-]{1,16}$
|
||||
var pattern = new Regex(@"^[a-zA-Z0-9/_=\-]{1,16}$");
|
||||
pattern.IsMatch(group).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class InterServerOpcodeRoutingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parser_dispatch_rejects_Aplus_for_client_kind_client_but_allows_for_gateway()
|
||||
{
|
||||
var m = new ClientCommandMatrix();
|
||||
m.IsAllowed(ClientKind.Client, "A+").ShouldBeFalse();
|
||||
m.IsAllowed(ClientKind.Gateway, "A+").ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class MessageTraceInitializationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Trace_context_is_initialized_from_connect_options()
|
||||
{
|
||||
var connectOpts = new ClientOptions
|
||||
{
|
||||
Name = "c1",
|
||||
Lang = "dotnet",
|
||||
Version = "1.0.0",
|
||||
Headers = true,
|
||||
};
|
||||
|
||||
var ctx = MessageTraceContext.CreateFromConnect(connectOpts);
|
||||
ctx.ClientName.ShouldBe("c1");
|
||||
ctx.ClientLang.ShouldBe("dotnet");
|
||||
ctx.ClientVersion.ShouldBe("1.0.0");
|
||||
ctx.HeadersEnabled.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests.ProtocolParity;
|
||||
|
||||
public class ProtoWireParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void ScanField_reads_tag_and_value_size_for_length_delimited_field()
|
||||
{
|
||||
// field=2, type=2, len=3, bytes=abc
|
||||
byte[] bytes = [0x12, 0x03, (byte)'a', (byte)'b', (byte)'c'];
|
||||
|
||||
var (number, wireType, size) = ProtoWire.ScanField(bytes);
|
||||
|
||||
number.ShouldBe(2);
|
||||
wireType.ShouldBe(2);
|
||||
size.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanTag_rejects_invalid_field_numbers()
|
||||
{
|
||||
var zeroFieldEx = Should.Throw<ProtoWireException>(() => ProtoWire.ScanTag([0x00]));
|
||||
zeroFieldEx.Message.ShouldBe(ProtoWire.ErrProtoInvalidFieldNumber);
|
||||
|
||||
var tooLargeTag = ProtoWire.EncodeVarint(((ulong)int.MaxValue + 1UL) << 3);
|
||||
var tooLargeEx = Should.Throw<ProtoWireException>(() => ProtoWire.ScanTag(tooLargeTag));
|
||||
tooLargeEx.Message.ShouldBe(ProtoWire.ErrProtoInvalidFieldNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFieldValue_supports_expected_wire_types()
|
||||
{
|
||||
ProtoWire.ScanFieldValue(5, [0, 0, 0, 0]).ShouldBe(4);
|
||||
ProtoWire.ScanFieldValue(1, [0, 0, 0, 0, 0, 0, 0, 0]).ShouldBe(8);
|
||||
ProtoWire.ScanFieldValue(0, [0x01]).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFieldValue_rejects_unsupported_wire_type()
|
||||
{
|
||||
var ex = Should.Throw<ProtoWireException>(() => ProtoWire.ScanFieldValue(3, [0x00]));
|
||||
ex.Message.ShouldBe("unsupported type: 3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanVarint_reports_insufficient_and_overflow_errors()
|
||||
{
|
||||
var insufficient = Should.Throw<ProtoWireException>(() => ProtoWire.ScanVarint([0x80]));
|
||||
insufficient.Message.ShouldBe(ProtoWire.ErrProtoInsufficient);
|
||||
|
||||
byte[] overflow = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x02];
|
||||
var tooLarge = Should.Throw<ProtoWireException>(() => ProtoWire.ScanVarint(overflow));
|
||||
tooLarge.Message.ShouldBe(ProtoWire.ErrProtoOverflow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanBytes_reports_insufficient_when_length_prefix_exceeds_payload()
|
||||
{
|
||||
var ex = Should.Throw<ProtoWireException>(() => ProtoWire.ScanBytes([0x04, 0x01, 0x02]));
|
||||
ex.Message.ShouldBe(ProtoWire.ErrProtoInsufficient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeVarint_round_trips_values_via_scan_varint()
|
||||
{
|
||||
ulong[] values =
|
||||
[
|
||||
0UL,
|
||||
1UL,
|
||||
127UL,
|
||||
128UL,
|
||||
16_383UL,
|
||||
16_384UL,
|
||||
(1UL << 32) - 1,
|
||||
ulong.MaxValue,
|
||||
];
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
var encoded = ProtoWire.EncodeVarint(value);
|
||||
var (decoded, size) = ProtoWire.ScanVarint(encoded);
|
||||
decoded.ShouldBe(value);
|
||||
size.ShouldBe(encoded.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests.ProtocolParity;
|
||||
|
||||
public class ProtocolDefaultConstantsGapParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void NatsProtocol_exposes_core_default_constants()
|
||||
{
|
||||
NatsProtocol.DefaultHost.ShouldBe("0.0.0.0");
|
||||
NatsProtocol.DefaultHttpPort.ShouldBe(8222);
|
||||
NatsProtocol.DefaultHttpBasePath.ShouldBe("/");
|
||||
NatsProtocol.DefaultRoutePoolSize.ShouldBe(3);
|
||||
NatsProtocol.DefaultLeafNodePort.ShouldBe(7422);
|
||||
NatsProtocol.MaxPayloadMaxSize.ShouldBe(8 * 1024 * 1024);
|
||||
NatsProtocol.DefaultMaxConnections.ShouldBe(64 * 1024);
|
||||
NatsProtocol.DefaultPingMaxOut.ShouldBe(2);
|
||||
NatsProtocol.DefaultMaxClosedClients.ShouldBe(10_000);
|
||||
NatsProtocol.DefaultConnectErrorReports.ShouldBe(3600);
|
||||
NatsProtocol.DefaultReconnectErrorReports.ShouldBe(1);
|
||||
NatsProtocol.DefaultAllowResponseMaxMsgs.ShouldBe(1);
|
||||
NatsProtocol.DefaultServiceLatencySampling.ShouldBe(100);
|
||||
NatsProtocol.DefaultSystemAccount.ShouldBe("$SYS");
|
||||
NatsProtocol.DefaultGlobalAccount.ShouldBe("$G");
|
||||
NatsProtocol.ProtoSnippetSize.ShouldBe(32);
|
||||
NatsProtocol.MaxControlLineSnippetSize.ShouldBe(128);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsProtocol_exposes_core_default_timespans()
|
||||
{
|
||||
NatsProtocol.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
NatsProtocol.DefaultTlsHandshakeFirstFallbackDelay.ShouldBe(TimeSpan.FromMilliseconds(50));
|
||||
NatsProtocol.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
NatsProtocol.DefaultRouteConnect.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultRouteConnectMax.ShouldBe(TimeSpan.FromSeconds(30));
|
||||
NatsProtocol.DefaultRouteReconnect.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultRouteDial.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultLeafNodeReconnect.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultLeafTlsTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
NatsProtocol.DefaultLeafNodeInfoWait.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultRttMeasurementInterval.ShouldBe(TimeSpan.FromHours(1));
|
||||
NatsProtocol.DefaultAllowResponseExpiration.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
NatsProtocol.DefaultServiceExportResponseThreshold.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
NatsProtocol.DefaultAccountFetchTimeout.ShouldBe(TimeSpan.FromMilliseconds(1900));
|
||||
NatsProtocol.DefaultPingInterval.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
NatsProtocol.DefaultFlushDeadline.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
NatsProtocol.AcceptMinSleep.ShouldBe(TimeSpan.FromMilliseconds(10));
|
||||
NatsProtocol.AcceptMaxSleep.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultLameDuckDuration.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
NatsProtocol.DefaultLameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsOptions_defaults_are_bound_to_protocol_defaults()
|
||||
{
|
||||
var options = new NatsOptions();
|
||||
|
||||
options.Host.ShouldBe(NatsProtocol.DefaultHost);
|
||||
options.Port.ShouldBe(NatsProtocol.DefaultPort);
|
||||
options.MaxConnections.ShouldBe(NatsProtocol.DefaultMaxConnections);
|
||||
options.AuthTimeout.ShouldBe(NatsProtocol.AuthTimeout);
|
||||
options.PingInterval.ShouldBe(NatsProtocol.DefaultPingInterval);
|
||||
options.MaxPingsOut.ShouldBe(NatsProtocol.DefaultPingMaxOut);
|
||||
options.WriteDeadline.ShouldBe(NatsProtocol.DefaultFlushDeadline);
|
||||
options.TlsTimeout.ShouldBe(NatsProtocol.TlsTimeout);
|
||||
options.TlsHandshakeFirstFallback.ShouldBe(NatsProtocol.DefaultTlsHandshakeFirstFallbackDelay);
|
||||
options.MaxClosedClients.ShouldBe(NatsProtocol.DefaultMaxClosedClients);
|
||||
options.LameDuckDuration.ShouldBe(NatsProtocol.DefaultLameDuckDuration);
|
||||
options.LameDuckGracePeriod.ShouldBe(NatsProtocol.DefaultLameDuckGracePeriod);
|
||||
options.ConnectErrorReports.ShouldBe(NatsProtocol.DefaultConnectErrorReports);
|
||||
options.ReconnectErrorReports.ShouldBe(NatsProtocol.DefaultReconnectErrorReports);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Buffers;
|
||||
using System.Text;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests.ProtocolParity;
|
||||
|
||||
public class ProtocolParserSnippetGapParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProtoSnippet_returns_empty_quotes_when_start_is_out_of_range()
|
||||
{
|
||||
var bytes = "PING"u8.ToArray();
|
||||
var snippet = NatsParser.ProtoSnippet(bytes.Length, 2, bytes);
|
||||
snippet.ShouldBe("\"\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtoSnippet_limits_to_requested_window_and_quotes_output()
|
||||
{
|
||||
var bytes = "ABCDEFGHIJ"u8.ToArray();
|
||||
var snippet = NatsParser.ProtoSnippet(2, 4, bytes);
|
||||
snippet.ShouldBe("\"CDEF\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtoSnippet_matches_go_behavior_when_max_runs_past_buffer_end()
|
||||
{
|
||||
var bytes = "ABCDE"u8.ToArray();
|
||||
var snippet = NatsParser.ProtoSnippet(0, 32, bytes);
|
||||
snippet.ShouldBe("\"ABCD\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_exceeding_max_control_line_includes_snippet_context_in_error()
|
||||
{
|
||||
var parser = new NatsParser();
|
||||
var longSubject = new string('a', NatsProtocol.MaxControlLineSize + 1);
|
||||
var input = Encoding.ASCII.GetBytes($"PUB {longSubject} 0\r\n\r\n");
|
||||
ReadOnlySequence<byte> buffer = new(input);
|
||||
|
||||
var ex = Should.Throw<ProtocolViolationException>(() => parser.TryParse(ref buffer, out _));
|
||||
ex.Message.ShouldContain("Maximum control line exceeded");
|
||||
ex.Message.ShouldContain("snip=");
|
||||
}
|
||||
}
|
||||
514
tests/NATS.Server.Core.Tests/Protocol/ProxyProtocolTests.cs
Normal file
514
tests/NATS.Server.Core.Tests/Protocol/ProxyProtocolTests.cs
Normal file
@@ -0,0 +1,514 @@
|
||||
// Go reference: golang/nats-server/server/client_proxyproto_test.go
|
||||
// Ports the PROXY protocol v1 and v2 parsing tests from the Go implementation.
|
||||
// The Go implementation uses a mock net.Conn; here we work directly with byte
|
||||
// buffers via the pure-parser surface ProxyProtocolParser.
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PROXY protocol v1/v2 parser tests.
|
||||
/// Ported from golang/nats-server/server/client_proxyproto_test.go.
|
||||
/// </summary>
|
||||
public class ProxyProtocolTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Build helpers (mirror the Go buildProxy* helpers)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Wraps the static builder for convenience inside tests.</summary>
|
||||
private static byte[] BuildV2Header(
|
||||
string srcIp, string dstIp, ushort srcPort, ushort dstPort, bool ipv6 = false)
|
||||
=> ProxyProtocolParser.BuildV2Header(srcIp, dstIp, srcPort, dstPort, ipv6);
|
||||
|
||||
private static byte[] BuildV2LocalHeader()
|
||||
=> ProxyProtocolParser.BuildV2LocalHeader();
|
||||
|
||||
private static byte[] BuildV1Header(
|
||||
string protocol, string srcIp = "", string dstIp = "", ushort srcPort = 0, ushort dstPort = 0)
|
||||
=> ProxyProtocolParser.BuildV1Header(protocol, srcIp, dstIp, srcPort, dstPort);
|
||||
|
||||
// =========================================================================
|
||||
// PROXY protocol v2 tests
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Parses a well-formed v2 PROXY header carrying an IPv4 source address and
|
||||
/// verifies that the extracted src/dst IP, port, and network string are correct.
|
||||
/// Ref: TestClientProxyProtoV2ParseIPv4 (client_proxyproto_test.go:155)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_parses_IPv4_address()
|
||||
{
|
||||
var header = BuildV2Header("192.168.1.50", "10.0.0.1", 12345, 4222);
|
||||
var result = ProxyProtocolParser.Parse(header);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address.ShouldNotBeNull();
|
||||
result.Address.SrcIp.ToString().ShouldBe("192.168.1.50");
|
||||
result.Address.SrcPort.ShouldBe((ushort)12345);
|
||||
result.Address.DstIp.ToString().ShouldBe("10.0.0.1");
|
||||
result.Address.DstPort.ShouldBe((ushort)4222);
|
||||
result.Address.ToString().ShouldBe("192.168.1.50:12345");
|
||||
result.Address.Network.ShouldBe("tcp4");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a well-formed v2 PROXY header carrying an IPv6 source address and
|
||||
/// verifies that the extracted src/dst IP, port, and network string are correct.
|
||||
/// Ref: TestClientProxyProtoV2ParseIPv6 (client_proxyproto_test.go:174)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_parses_IPv6_address()
|
||||
{
|
||||
var header = BuildV2Header("2001:db8::1", "2001:db8::2", 54321, 4222, ipv6: true);
|
||||
var result = ProxyProtocolParser.Parse(header);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address.ShouldNotBeNull();
|
||||
result.Address.SrcIp.ToString().ShouldBe("2001:db8::1");
|
||||
result.Address.SrcPort.ShouldBe((ushort)54321);
|
||||
result.Address.DstIp.ToString().ShouldBe("2001:db8::2");
|
||||
result.Address.DstPort.ShouldBe((ushort)4222);
|
||||
result.Address.ToString().ShouldBe("[2001:db8::1]:54321");
|
||||
result.Address.Network.ShouldBe("tcp6");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A LOCAL command header (health check) must parse successfully and return
|
||||
/// a Local result with no address.
|
||||
/// Ref: TestClientProxyProtoV2ParseLocalCommand (client_proxyproto_test.go:193)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_LOCAL_command_returns_local_result()
|
||||
{
|
||||
var header = BuildV2LocalHeader();
|
||||
var result = ProxyProtocolParser.Parse(header);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Local);
|
||||
result.Address.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v2 header with an invalid 12-byte signature must throw
|
||||
/// <see cref="ProxyProtocolException"/>. The test calls <see cref="ProxyProtocolParser.ParseV2"/>
|
||||
/// directly so the full-signature check is exercised (auto-detection would classify the
|
||||
/// buffer as "unrecognized" before reaching the signature comparison).
|
||||
/// Ref: TestClientProxyProtoV2InvalidSignature (client_proxyproto_test.go:202)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_invalid_signature_throws()
|
||||
{
|
||||
// Build a 16-byte buffer whose first 12 bytes are garbage — ParseV2 must
|
||||
// reject it because the full signature comparison fails.
|
||||
var header = new byte[16];
|
||||
Encoding.ASCII.GetBytes("INVALID_SIG_").CopyTo(header, 0);
|
||||
header[12] = 0x20; // ver/cmd
|
||||
header[13] = 0x11; // fam/proto
|
||||
header[14] = 0x00;
|
||||
header[15] = 0x0C;
|
||||
|
||||
// Use ParseV2 directly — this validates the complete 12-byte signature.
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.ParseV2(header));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v2 header where the version nibble is not 2 must be rejected.
|
||||
/// Ref: TestClientProxyProtoV2InvalidVersion (client_proxyproto_test.go:212)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_invalid_version_nibble_throws()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A")); // valid sig
|
||||
ms.WriteByte(0x10 | 0x01); // version = 1 (wrong), command = PROXY
|
||||
ms.WriteByte(0x10 | 0x01); // family = IPv4, proto = STREAM
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x00);
|
||||
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.ParseV2(ms.ToArray()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v2 PROXY command with the Unix socket address family must be rejected
|
||||
/// with an unsupported-feature exception.
|
||||
/// Ref: TestClientProxyProtoV2UnsupportedFamily (client_proxyproto_test.go:226)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_unix_socket_family_is_unsupported()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"));
|
||||
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
|
||||
ms.WriteByte(0x30 | 0x01); // family=Unix, proto=STREAM
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x00);
|
||||
|
||||
Should.Throw<ProxyProtocolUnsupportedException>(() => ProxyProtocolParser.ParseV2(ms.ToArray()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v2 PROXY command with the UDP (Datagram) protocol must be rejected
|
||||
/// with an unsupported-feature exception.
|
||||
/// Ref: TestClientProxyProtoV2UnsupportedProtocol (client_proxyproto_test.go:240)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_datagram_protocol_is_unsupported()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"));
|
||||
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
|
||||
ms.WriteByte(0x10 | 0x02); // family=IPv4, proto=DATAGRAM (UDP)
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x0C); // addr-len = 12
|
||||
|
||||
Should.Throw<ProxyProtocolUnsupportedException>(() => ProxyProtocolParser.ParseV2(ms.ToArray()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A truncated v2 header (only 10 of the required 16 bytes) must throw.
|
||||
/// Ref: TestClientProxyProtoV2TruncatedHeader (client_proxyproto_test.go:254)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_truncated_header_throws()
|
||||
{
|
||||
var full = BuildV2Header("192.168.1.50", "10.0.0.1", 12345, 4222);
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(full[..10]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v2 header whose address-length field says 12 bytes but the buffer
|
||||
/// supplies only 5 bytes must throw.
|
||||
/// Ref: TestClientProxyProtoV2ShortAddressData (client_proxyproto_test.go:263)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_short_address_data_throws()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"));
|
||||
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
|
||||
ms.WriteByte(0x10 | 0x01); // family=IPv4, proto=STREAM
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x0C); // addr-len = 12
|
||||
// Write only 5 bytes of address data instead of 12
|
||||
ms.Write(new byte[] { 1, 2, 3, 4, 5 });
|
||||
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.ParseV2(ms.ToArray()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ProxyAddress.ToString() returns "ip:port" for IPv4 and "[ip]:port" for IPv6;
|
||||
/// ProxyAddress.Network() returns "tcp4" or "tcp6" accordingly.
|
||||
/// Ref: TestProxyConnRemoteAddr (client_proxyproto_test.go:280)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ProxyAddress_string_and_network_are_correct()
|
||||
{
|
||||
var ipv4Addr = new ProxyAddress
|
||||
{
|
||||
SrcIp = IPAddress.Parse("10.0.0.50"),
|
||||
SrcPort = 12345,
|
||||
DstIp = IPAddress.Parse("10.0.0.1"),
|
||||
DstPort = 4222,
|
||||
};
|
||||
ipv4Addr.ToString().ShouldBe("10.0.0.50:12345");
|
||||
ipv4Addr.Network.ShouldBe("tcp4");
|
||||
|
||||
var ipv6Addr = new ProxyAddress
|
||||
{
|
||||
SrcIp = IPAddress.Parse("2001:db8::1"),
|
||||
SrcPort = 54321,
|
||||
DstIp = IPAddress.Parse("2001:db8::2"),
|
||||
DstPort = 4222,
|
||||
};
|
||||
ipv6Addr.ToString().ShouldBe("[2001:db8::1]:54321");
|
||||
ipv6Addr.Network.ShouldBe("tcp6");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PROXY protocol v1 tests
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// A well-formed TCP4 v1 header is parsed and the source address is returned.
|
||||
/// Ref: TestClientProxyProtoV1ParseTCP4 (client_proxyproto_test.go:416)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_parses_TCP4_address()
|
||||
{
|
||||
var header = BuildV1Header("TCP4", "192.168.1.50", "10.0.0.1", 12345, 4222);
|
||||
var result = ProxyProtocolParser.Parse(header);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address.ShouldNotBeNull();
|
||||
result.Address.SrcIp.ToString().ShouldBe("192.168.1.50");
|
||||
result.Address.SrcPort.ShouldBe((ushort)12345);
|
||||
result.Address.DstIp.ToString().ShouldBe("10.0.0.1");
|
||||
result.Address.DstPort.ShouldBe((ushort)4222);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A well-formed TCP6 v1 header is parsed and the source IPv6 address is returned.
|
||||
/// Ref: TestClientProxyProtoV1ParseTCP6 (client_proxyproto_test.go:431)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_parses_TCP6_address()
|
||||
{
|
||||
var header = BuildV1Header("TCP6", "2001:db8::1", "2001:db8::2", 54321, 4222);
|
||||
var result = ProxyProtocolParser.Parse(header);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address.ShouldNotBeNull();
|
||||
result.Address.SrcIp.ToString().ShouldBe("2001:db8::1");
|
||||
result.Address.SrcPort.ShouldBe((ushort)54321);
|
||||
result.Address.DstIp.ToString().ShouldBe("2001:db8::2");
|
||||
result.Address.DstPort.ShouldBe((ushort)4222);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An UNKNOWN v1 header (health check) must return a Local result with no address.
|
||||
/// Ref: TestClientProxyProtoV1ParseUnknown (client_proxyproto_test.go:446)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_UNKNOWN_returns_local_result()
|
||||
{
|
||||
var header = BuildV1Header("UNKNOWN");
|
||||
var result = ProxyProtocolParser.Parse(header);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Local);
|
||||
result.Address.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v1 header with too few fields (e.g. missing port tokens) must throw.
|
||||
/// Ref: TestClientProxyProtoV1InvalidFormat (client_proxyproto_test.go:455)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_missing_fields_throws()
|
||||
{
|
||||
// "PROXY TCP4 192.168.1.1\r\n" — only 1 token after PROXY
|
||||
var header = Encoding.ASCII.GetBytes("PROXY TCP4 192.168.1.1\r\n");
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v1 line longer than 107 bytes without a CRLF must throw.
|
||||
/// Ref: TestClientProxyProtoV1LineTooLong (client_proxyproto_test.go:464)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_line_too_long_throws()
|
||||
{
|
||||
var longIp = new string('1', 120);
|
||||
var header = Encoding.ASCII.GetBytes($"PROXY TCP4 {longIp} 10.0.0.1 12345 443\r\n");
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v1 header whose IP token is not a parseable IP address must throw.
|
||||
/// Ref: TestClientProxyProtoV1InvalidIP (client_proxyproto_test.go:474)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_invalid_IP_address_throws()
|
||||
{
|
||||
var header = Encoding.ASCII.GetBytes("PROXY TCP4 not.an.ip.addr 10.0.0.1 12345 443\r\n");
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TCP4 protocol with an IPv6 source address, and TCP6 protocol with an IPv4
|
||||
/// source address, must both throw a protocol-mismatch exception.
|
||||
/// Ref: TestClientProxyProtoV1MismatchedProtocol (client_proxyproto_test.go:482)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_TCP4_with_IPv6_address_throws()
|
||||
{
|
||||
var header = BuildV1Header("TCP4", "2001:db8::1", "2001:db8::2", 12345, 443);
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void V1_TCP6_with_IPv4_address_throws()
|
||||
{
|
||||
var header = BuildV1Header("TCP6", "192.168.1.1", "10.0.0.1", 12345, 443);
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A port value that exceeds 65535 cannot be parsed as ushort and must throw.
|
||||
/// Ref: TestClientProxyProtoV1InvalidPort (client_proxyproto_test.go:498)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_port_out_of_range_throws()
|
||||
{
|
||||
var header = Encoding.ASCII.GetBytes("PROXY TCP4 192.168.1.1 10.0.0.1 99999 443\r\n");
|
||||
Should.Throw<Exception>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Mixed version detection tests
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// The auto-detection logic correctly routes a "PROXY " prefix to the v1 parser
|
||||
/// and a binary v2 signature to the v2 parser, extracting the correct source address.
|
||||
/// Ref: TestClientProxyProtoVersionDetection (client_proxyproto_test.go:567)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Auto_detection_routes_v1_and_v2_correctly()
|
||||
{
|
||||
var v1Header = BuildV1Header("TCP4", "192.168.1.1", "10.0.0.1", 12345, 443);
|
||||
var r1 = ProxyProtocolParser.Parse(v1Header);
|
||||
r1.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
r1.Address!.SrcIp.ToString().ShouldBe("192.168.1.1");
|
||||
|
||||
var v2Header = BuildV2Header("192.168.1.2", "10.0.0.1", 54321, 443);
|
||||
var r2 = ProxyProtocolParser.Parse(v2Header);
|
||||
r2.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
r2.Address!.SrcIp.ToString().ShouldBe("192.168.1.2");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A header that starts with neither "PROXY " nor the v2 binary signature must
|
||||
/// throw a <see cref="ProxyProtocolException"/> indicating the format is unrecognized.
|
||||
/// Ref: TestClientProxyProtoUnrecognizedVersion (client_proxyproto_test.go:587)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Unrecognized_header_throws()
|
||||
{
|
||||
var header = Encoding.ASCII.GetBytes("HELLO WORLD\r\n");
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A data buffer shorter than 6 bytes cannot carry any valid PROXY header prefix
|
||||
/// and must throw.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Too_short_input_throws()
|
||||
{
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(new byte[] { 0x50, 0x52 }));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Additional edge cases (not directly from Go tests but needed for full coverage)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// ParseV1 operating directly on the bytes after the "PROXY " prefix correctly
|
||||
/// extracts a TCP4 address without going through the auto-detector.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseV1_direct_entry_point_works()
|
||||
{
|
||||
var afterPrefix = Encoding.ASCII.GetBytes("TCP4 1.2.3.4 5.6.7.8 1234 4222\r\n");
|
||||
var result = ProxyProtocolParser.ParseV1(afterPrefix);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address!.SrcIp.ToString().ShouldBe("1.2.3.4");
|
||||
result.Address.SrcPort.ShouldBe((ushort)1234);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ParseV2AfterSig operating on the 4-byte post-signature header correctly parses
|
||||
/// a PROXY command with the full IPv4 address block appended.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseV2AfterSig_direct_entry_point_works()
|
||||
{
|
||||
// Build just the 4 header bytes + 12 address bytes (no sig)
|
||||
var ms = new MemoryStream();
|
||||
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
|
||||
ms.WriteByte(0x10 | 0x01); // family=IPv4, proto=STREAM
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x0C); // addr-len = 12
|
||||
// src IP 192.168.0.1, dst IP 10.0.0.1, src port 9999, dst port 4222
|
||||
ms.Write(IPAddress.Parse("192.168.0.1").GetAddressBytes());
|
||||
ms.Write(IPAddress.Parse("10.0.0.1").GetAddressBytes());
|
||||
var ports = new byte[4];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(ports.AsSpan(0), 9999);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(ports.AsSpan(2), 4222);
|
||||
ms.Write(ports);
|
||||
|
||||
var result = ProxyProtocolParser.ParseV2AfterSig(ms.ToArray());
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address!.SrcIp.ToString().ShouldBe("192.168.0.1");
|
||||
result.Address.SrcPort.ShouldBe((ushort)9999);
|
||||
result.Address.DstPort.ShouldBe((ushort)4222);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v2 UNSPEC family with PROXY command returns a Local result (no address override).
|
||||
/// The Go implementation discards unspec address data and returns nil addr.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_UNSPEC_family_returns_local()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"));
|
||||
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
|
||||
ms.WriteByte(0x00 | 0x01); // family=UNSPEC, proto=STREAM
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x00); // addr-len = 0
|
||||
|
||||
var result = ProxyProtocolParser.ParseV2(ms.ToArray());
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Local);
|
||||
result.Address.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BuildV2Header round-trips — parsing the output of the builder yields the same
|
||||
/// addresses that were passed in, for both IPv4 and IPv6.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildV2Header_round_trips_IPv4()
|
||||
{
|
||||
var bytes = BuildV2Header("203.0.113.50", "127.0.0.1", 54321, 4222);
|
||||
var result = ProxyProtocolParser.Parse(bytes);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address!.SrcIp.ToString().ShouldBe("203.0.113.50");
|
||||
result.Address.SrcPort.ShouldBe((ushort)54321);
|
||||
result.Address.DstIp.ToString().ShouldBe("127.0.0.1");
|
||||
result.Address.DstPort.ShouldBe((ushort)4222);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildV2Header_round_trips_IPv6()
|
||||
{
|
||||
var bytes = BuildV2Header("fe80::1", "fe80::2", 1234, 4222, ipv6: true);
|
||||
var result = ProxyProtocolParser.Parse(bytes);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address!.Network.ShouldBe("tcp6");
|
||||
result.Address.SrcPort.ShouldBe((ushort)1234);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BuildV1Header round-trips for both TCP4 and TCP6 lines.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildV1Header_round_trips_TCP4()
|
||||
{
|
||||
var bytes = BuildV1Header("TCP4", "203.0.113.50", "127.0.0.1", 54321, 4222);
|
||||
var result = ProxyProtocolParser.Parse(bytes);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address!.SrcIp.ToString().ShouldBe("203.0.113.50");
|
||||
result.Address.SrcPort.ShouldBe((ushort)54321);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildV1Header_round_trips_TCP6()
|
||||
{
|
||||
var bytes = BuildV1Header("TCP6", "2001:db8::cafe", "2001:db8::1", 11111, 4222);
|
||||
var result = ProxyProtocolParser.Parse(bytes);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address!.SrcIp.ToString().ShouldBe("2001:db8::cafe");
|
||||
result.Address.SrcPort.ShouldBe((ushort)11111);
|
||||
}
|
||||
}
|
||||
149
tests/NATS.Server.Core.Tests/ResponseRoutingTests.cs
Normal file
149
tests/NATS.Server.Core.Tests/ResponseRoutingTests.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ResponseRoutingTests
|
||||
{
|
||||
[Fact]
|
||||
public void GenerateReplyPrefix_creates_unique_prefix()
|
||||
{
|
||||
var prefix1 = ResponseRouter.GenerateReplyPrefix();
|
||||
var prefix2 = ResponseRouter.GenerateReplyPrefix();
|
||||
|
||||
prefix1.ShouldStartWith("_R_.");
|
||||
prefix2.ShouldStartWith("_R_.");
|
||||
prefix1.ShouldNotBe(prefix2);
|
||||
prefix1.Length.ShouldBeGreaterThan(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateReplyPrefix_ends_with_dot()
|
||||
{
|
||||
var prefix = ResponseRouter.GenerateReplyPrefix();
|
||||
|
||||
prefix.ShouldEndWith(".");
|
||||
// Format: "_R_." + 10 chars + "." = 15 chars
|
||||
prefix.Length.ShouldBe(15);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Singleton_response_import_removed_after_delivery()
|
||||
{
|
||||
var exporter = new Account("exporter");
|
||||
exporter.AddServiceExport("api.test", ServiceResponseType.Singleton, null);
|
||||
|
||||
var replyPrefix = ResponseRouter.GenerateReplyPrefix();
|
||||
var responseSi = new ServiceImport
|
||||
{
|
||||
DestinationAccount = exporter,
|
||||
From = replyPrefix + ">",
|
||||
To = "_INBOX.original.reply",
|
||||
IsResponse = true,
|
||||
ResponseType = ServiceResponseType.Singleton,
|
||||
};
|
||||
exporter.Exports.Responses[replyPrefix] = responseSi;
|
||||
|
||||
exporter.Exports.Responses.ShouldContainKey(replyPrefix);
|
||||
|
||||
// Simulate singleton delivery cleanup
|
||||
ResponseRouter.CleanupResponse(exporter, replyPrefix, responseSi);
|
||||
|
||||
exporter.Exports.Responses.ShouldNotContainKey(replyPrefix);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateResponseImport_registers_in_exporter_responses()
|
||||
{
|
||||
var exporter = new Account("exporter");
|
||||
var importer = new Account("importer");
|
||||
exporter.AddServiceExport("api.test", ServiceResponseType.Singleton, null);
|
||||
|
||||
var originalSi = new ServiceImport
|
||||
{
|
||||
DestinationAccount = exporter,
|
||||
From = "api.test",
|
||||
To = "api.test",
|
||||
Export = exporter.Exports.Services["api.test"],
|
||||
ResponseType = ServiceResponseType.Singleton,
|
||||
};
|
||||
|
||||
var responseSi = ResponseRouter.CreateResponseImport(exporter, originalSi, "_INBOX.abc123");
|
||||
|
||||
responseSi.IsResponse.ShouldBeTrue();
|
||||
responseSi.ResponseType.ShouldBe(ServiceResponseType.Singleton);
|
||||
responseSi.To.ShouldBe("_INBOX.abc123");
|
||||
responseSi.DestinationAccount.ShouldBe(exporter);
|
||||
responseSi.From.ShouldEndWith(">");
|
||||
responseSi.Export.ShouldBe(originalSi.Export);
|
||||
|
||||
// Should be registered in the exporter's response map
|
||||
exporter.Exports.Responses.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateResponseImport_preserves_streamed_response_type()
|
||||
{
|
||||
var exporter = new Account("exporter");
|
||||
exporter.AddServiceExport("api.stream", ServiceResponseType.Streamed, null);
|
||||
|
||||
var originalSi = new ServiceImport
|
||||
{
|
||||
DestinationAccount = exporter,
|
||||
From = "api.stream",
|
||||
To = "api.stream",
|
||||
Export = exporter.Exports.Services["api.stream"],
|
||||
ResponseType = ServiceResponseType.Streamed,
|
||||
};
|
||||
|
||||
var responseSi = ResponseRouter.CreateResponseImport(exporter, originalSi, "_INBOX.xyz789");
|
||||
|
||||
responseSi.ResponseType.ShouldBe(ServiceResponseType.Streamed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_response_imports_each_get_unique_prefix()
|
||||
{
|
||||
var exporter = new Account("exporter");
|
||||
exporter.AddServiceExport("api.test", ServiceResponseType.Singleton, null);
|
||||
|
||||
var originalSi = new ServiceImport
|
||||
{
|
||||
DestinationAccount = exporter,
|
||||
From = "api.test",
|
||||
To = "api.test",
|
||||
Export = exporter.Exports.Services["api.test"],
|
||||
ResponseType = ServiceResponseType.Singleton,
|
||||
};
|
||||
|
||||
var resp1 = ResponseRouter.CreateResponseImport(exporter, originalSi, "_INBOX.reply1");
|
||||
var resp2 = ResponseRouter.CreateResponseImport(exporter, originalSi, "_INBOX.reply2");
|
||||
|
||||
exporter.Exports.Responses.Count.ShouldBe(2);
|
||||
resp1.To.ShouldBe("_INBOX.reply1");
|
||||
resp2.To.ShouldBe("_INBOX.reply2");
|
||||
resp1.From.ShouldNotBe(resp2.From);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LatencyTracker_should_sample_respects_percentage()
|
||||
{
|
||||
var latency = new ServiceLatency { SamplingPercentage = 0, Subject = "latency.test" };
|
||||
LatencyTracker.ShouldSample(latency).ShouldBeFalse();
|
||||
|
||||
var latency100 = new ServiceLatency { SamplingPercentage = 100, Subject = "latency.test" };
|
||||
LatencyTracker.ShouldSample(latency100).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LatencyTracker_builds_latency_message()
|
||||
{
|
||||
var msg = LatencyTracker.BuildLatencyMsg("requester", "responder",
|
||||
TimeSpan.FromMilliseconds(5), TimeSpan.FromMilliseconds(10));
|
||||
|
||||
msg.Requestor.ShouldBe("requester");
|
||||
msg.Responder.ShouldBe("responder");
|
||||
msg.ServiceLatencyNanos.ShouldBeGreaterThan(0);
|
||||
msg.TotalLatencyNanos.ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
51
tests/NATS.Server.Core.Tests/ResponseTrackerTests.cs
Normal file
51
tests/NATS.Server.Core.Tests/ResponseTrackerTests.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ResponseTrackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Allows_reply_subject_after_registration()
|
||||
{
|
||||
var tracker = new ResponseTracker(maxMsgs: 1, expires: TimeSpan.FromMinutes(5));
|
||||
tracker.RegisterReply("_INBOX.abc123");
|
||||
tracker.IsReplyAllowed("_INBOX.abc123").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denies_unknown_reply_subject()
|
||||
{
|
||||
var tracker = new ResponseTracker(maxMsgs: 1, expires: TimeSpan.FromMinutes(5));
|
||||
tracker.IsReplyAllowed("_INBOX.unknown").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enforces_max_messages()
|
||||
{
|
||||
var tracker = new ResponseTracker(maxMsgs: 2, expires: TimeSpan.FromMinutes(5));
|
||||
tracker.RegisterReply("_INBOX.abc");
|
||||
tracker.IsReplyAllowed("_INBOX.abc").ShouldBeTrue();
|
||||
tracker.IsReplyAllowed("_INBOX.abc").ShouldBeTrue();
|
||||
tracker.IsReplyAllowed("_INBOX.abc").ShouldBeFalse(); // exceeded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enforces_expiry()
|
||||
{
|
||||
var tracker = new ResponseTracker(maxMsgs: 0, expires: TimeSpan.FromMilliseconds(1));
|
||||
tracker.RegisterReply("_INBOX.abc");
|
||||
Thread.Sleep(50);
|
||||
tracker.IsReplyAllowed("_INBOX.abc").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prune_removes_expired()
|
||||
{
|
||||
var tracker = new ResponseTracker(maxMsgs: 0, expires: TimeSpan.FromMilliseconds(1));
|
||||
tracker.RegisterReply("_INBOX.a");
|
||||
tracker.RegisterReply("_INBOX.b");
|
||||
Thread.Sleep(50);
|
||||
tracker.Prune();
|
||||
tracker.Count.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
118
tests/NATS.Server.Core.Tests/RttTests.cs
Normal file
118
tests/NATS.Server.Core.Tests/RttTests.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Monitoring;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class RttTests : IAsyncLifetime
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly int _natsPort;
|
||||
private readonly int _monitorPort;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly HttpClient _http = new();
|
||||
|
||||
public RttTests()
|
||||
{
|
||||
_natsPort = TestPortAllocator.GetFreePort();
|
||||
_monitorPort = TestPortAllocator.GetFreePort();
|
||||
_server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
Port = _natsPort,
|
||||
MonitorPort = _monitorPort,
|
||||
PingInterval = TimeSpan.FromMilliseconds(200),
|
||||
MaxPingsOut = 4,
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_ = _server.StartAsync(_cts.Token);
|
||||
await _server.WaitForReadyAsync();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
|
||||
if (resp.IsSuccessStatusCode) break;
|
||||
}
|
||||
catch (HttpRequestException) { }
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
_http.Dispose();
|
||||
await _cts.CancelAsync();
|
||||
_server.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rtt_populated_after_ping_pong_cycle()
|
||||
{
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
|
||||
using var stream = new NetworkStream(sock);
|
||||
var buf = new byte[4096];
|
||||
_ = await stream.ReadAsync(buf); // INFO
|
||||
|
||||
// Send CONNECT + PING (triggers firstPongSent)
|
||||
await stream.WriteAsync("CONNECT {}\r\nPING\r\n"u8.ToArray());
|
||||
await stream.FlushAsync();
|
||||
_ = await stream.ReadAsync(buf); // PONG
|
||||
|
||||
// Wait for server's PING cycle
|
||||
await Task.Delay(500);
|
||||
|
||||
// Read server PING and respond with PONG
|
||||
var received = new byte[4096];
|
||||
int totalRead = 0;
|
||||
bool gotPing = false;
|
||||
using var readCts = new CancellationTokenSource(2000);
|
||||
while (!gotPing && !readCts.IsCancellationRequested)
|
||||
{
|
||||
var n = await stream.ReadAsync(received.AsMemory(totalRead), readCts.Token);
|
||||
totalRead += n;
|
||||
var text = System.Text.Encoding.ASCII.GetString(received, 0, totalRead);
|
||||
if (text.Contains("PING"))
|
||||
{
|
||||
gotPing = true;
|
||||
await stream.WriteAsync("PONG\r\n"u8.ToArray());
|
||||
await stream.FlushAsync();
|
||||
}
|
||||
}
|
||||
|
||||
gotPing.ShouldBeTrue("Server should have sent PING");
|
||||
|
||||
// Wait for RTT to be computed
|
||||
await Task.Delay(200);
|
||||
|
||||
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz");
|
||||
var connz = await response.Content.ReadFromJsonAsync<Connz>();
|
||||
connz.ShouldNotBeNull();
|
||||
var conn = connz.Conns.FirstOrDefault(c => c.Rtt != "");
|
||||
conn.ShouldNotBeNull("At least one connection should have RTT populated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connz_sort_by_rtt()
|
||||
{
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
|
||||
using var stream = new NetworkStream(sock);
|
||||
var buf = new byte[4096];
|
||||
_ = await stream.ReadAsync(buf);
|
||||
await stream.WriteAsync("CONNECT {}\r\n"u8.ToArray());
|
||||
await Task.Delay(200);
|
||||
|
||||
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=rtt");
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Server;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class AcceptLoopErrorCallbackTests
|
||||
{
|
||||
[Fact]
|
||||
public void Accept_loop_reports_error_via_callback_hook()
|
||||
{
|
||||
var server = new NatsServer(new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
}, NullLoggerFactory.Instance);
|
||||
|
||||
Exception? capturedError = null;
|
||||
EndPoint? capturedEndpoint = null;
|
||||
var capturedDelay = TimeSpan.Zero;
|
||||
|
||||
var handler = new AcceptLoopErrorHandler((ex, endpoint, delay) =>
|
||||
{
|
||||
capturedError = ex;
|
||||
capturedEndpoint = endpoint;
|
||||
capturedDelay = delay;
|
||||
});
|
||||
|
||||
server.SetAcceptLoopErrorHandlerForTest(handler);
|
||||
|
||||
var endpoint = new IPEndPoint(IPAddress.Loopback, 4222);
|
||||
var error = new SocketException((int)SocketError.ConnectionReset);
|
||||
var delay = TimeSpan.FromMilliseconds(20);
|
||||
server.NotifyAcceptErrorForTest(error, endpoint, delay);
|
||||
|
||||
capturedError.ShouldBe(error);
|
||||
capturedEndpoint.ShouldBe(endpoint);
|
||||
capturedDelay.ShouldBe(delay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class AcceptLoopReloadLockTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Accept_loop_blocks_client_creation_while_reload_lock_is_held()
|
||||
{
|
||||
await using var fx = await AcceptLoopFixture.StartAsync();
|
||||
await fx.HoldReloadLockAsync();
|
||||
(await fx.TryConnectClientAsync(timeoutMs: 150)).ShouldBeFalse();
|
||||
fx.ReleaseReloadLock();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AcceptLoopFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
private bool _reloadHeld;
|
||||
|
||||
private AcceptLoopFixture(NatsServer server, CancellationTokenSource cts)
|
||||
{
|
||||
_server = server;
|
||||
_cts = cts;
|
||||
}
|
||||
|
||||
public static async Task<AcceptLoopFixture> StartAsync()
|
||||
{
|
||||
var server = new NatsServer(new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
}, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return new AcceptLoopFixture(server, cts);
|
||||
}
|
||||
|
||||
public async Task HoldReloadLockAsync()
|
||||
{
|
||||
await _server.AcquireReloadLockForTestAsync();
|
||||
_reloadHeld = true;
|
||||
}
|
||||
|
||||
public void ReleaseReloadLock()
|
||||
{
|
||||
if (_reloadHeld)
|
||||
{
|
||||
_server.ReleaseReloadLockForTest();
|
||||
_reloadHeld = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TryConnectClientAsync(int timeoutMs)
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
await client.ConnectAsync(IPAddress.Loopback, _server.Port, timeout.Token);
|
||||
await using var stream = client.GetStream();
|
||||
var buffer = new byte[1];
|
||||
try
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(0, 1), timeout.Token);
|
||||
return read > 0;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
ReleaseReloadLock();
|
||||
await _cts.CancelAsync();
|
||||
_server.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Server;
|
||||
|
||||
public class CoreServerClientAccessorsParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Client_protocol_constants_match_go_values()
|
||||
{
|
||||
ClientProtocolVersion.ClientProtoZero.ShouldBe(0);
|
||||
ClientProtocolVersion.ClientProtoInfo.ShouldBe(1);
|
||||
|
||||
((int)ClientConnectionType.NonClient).ShouldBe(0);
|
||||
((int)ClientConnectionType.Nats).ShouldBe(1);
|
||||
((int)ClientConnectionType.Mqtt).ShouldBe(2);
|
||||
((int)ClientConnectionType.WebSocket).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsClient_getters_and_client_type_behave_as_expected()
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
var opts = new NatsOptions();
|
||||
var info = new ServerInfo
|
||||
{
|
||||
ServerId = "srv1",
|
||||
ServerName = "srv",
|
||||
Version = "1.0.0",
|
||||
Host = "127.0.0.1",
|
||||
Port = 4222,
|
||||
};
|
||||
var auth = AuthService.Build(opts);
|
||||
var nonce = new byte[] { 1, 2, 3 };
|
||||
var stats = new ServerStats();
|
||||
|
||||
using var client = new NatsClient(
|
||||
id: 42,
|
||||
stream: stream,
|
||||
socket: socket,
|
||||
options: opts,
|
||||
serverInfo: info,
|
||||
authService: auth,
|
||||
nonce: nonce,
|
||||
logger: NullLogger.Instance,
|
||||
serverStats: stats);
|
||||
|
||||
client.ClientType().ShouldBe(ClientConnectionType.Nats);
|
||||
client.IsWebSocket = true;
|
||||
client.ClientType().ShouldBe(ClientConnectionType.WebSocket);
|
||||
client.IsWebSocket = false;
|
||||
client.IsMqtt = true;
|
||||
client.ClientType().ShouldBe(ClientConnectionType.Mqtt);
|
||||
client.GetName().ShouldBe(string.Empty);
|
||||
client.GetNonce().ShouldBe(nonce);
|
||||
client.ToString().ShouldContain("cid=42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsClient_client_type_non_client_when_kind_is_not_client()
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
var opts = new NatsOptions();
|
||||
var info = new ServerInfo
|
||||
{
|
||||
ServerId = "srv1",
|
||||
ServerName = "srv",
|
||||
Version = "1.0.0",
|
||||
Host = "127.0.0.1",
|
||||
Port = 4222,
|
||||
};
|
||||
var auth = AuthService.Build(opts);
|
||||
var stats = new ServerStats();
|
||||
|
||||
using var routeClient = new NatsClient(
|
||||
id: 7,
|
||||
stream: stream,
|
||||
socket: socket,
|
||||
options: opts,
|
||||
serverInfo: info,
|
||||
authService: auth,
|
||||
nonce: null,
|
||||
logger: NullLogger.Instance,
|
||||
serverStats: stats,
|
||||
kind: ClientKind.Router);
|
||||
|
||||
routeClient.ClientType().ShouldBe(ClientConnectionType.NonClient);
|
||||
}
|
||||
}
|
||||
269
tests/NATS.Server.Core.Tests/Server/CoreServerGapParityTests.cs
Normal file
269
tests/NATS.Server.Core.Tests/Server/CoreServerGapParityTests.cs
Normal file
@@ -0,0 +1,269 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Server;
|
||||
|
||||
public class CoreServerGapParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void ClientURL_uses_advertise_when_present()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions { Host = "0.0.0.0", Port = 4222, ClientAdvertise = "demo.example.net:4333" },
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
server.ClientURL().ShouldBe("nats://demo.example.net:4333");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientURL_uses_loopback_for_wildcard_host()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions { Host = "0.0.0.0", Port = 4222 },
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
server.ClientURL().ShouldBe("nats://127.0.0.1:4222");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebsocketURL_uses_default_host_port_when_enabled()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
WebSocket = new WebSocketOptions
|
||||
{
|
||||
Host = "0.0.0.0",
|
||||
Port = 8080,
|
||||
NoTls = true,
|
||||
},
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
server.WebsocketURL().ShouldBe("ws://127.0.0.1:8080");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebsocketURL_returns_null_when_disabled()
|
||||
{
|
||||
using var server = new NatsServer(new NatsOptions(), NullLoggerFactory.Instance);
|
||||
|
||||
server.WebsocketURL().ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_count_methods_reflect_loaded_and_active_accounts()
|
||||
{
|
||||
using var server = new NatsServer(new NatsOptions(), NullLoggerFactory.Instance);
|
||||
|
||||
server.NumLoadedAccounts().ShouldBe(2); // $G + $SYS
|
||||
server.NumActiveAccounts().ShouldBe(0);
|
||||
|
||||
var app = server.GetOrCreateAccount("APP");
|
||||
server.NumLoadedAccounts().ShouldBe(3);
|
||||
|
||||
app.AddClient(42);
|
||||
server.NumActiveAccounts().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Address_and_counter_methods_are_derived_from_options_and_stats()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 4222,
|
||||
MonitorHost = "127.0.0.1",
|
||||
MonitorPort = 8222,
|
||||
ProfPort = 6060,
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
server.Stats.Routes = 2;
|
||||
server.Stats.Gateways = 1;
|
||||
server.Stats.Leafs = 3;
|
||||
|
||||
server.Addr().ShouldBe("127.0.0.1:4222");
|
||||
server.MonitorAddr().ShouldBe("127.0.0.1:8222");
|
||||
server.ProfilerAddr().ShouldBe("127.0.0.1:6060");
|
||||
server.NumRoutes().ShouldBe(2);
|
||||
server.NumLeafNodes().ShouldBe(3);
|
||||
server.NumRemotes().ShouldBe(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_includes_identity_and_address()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions { ServerName = "test-node", Host = "127.0.0.1", Port = 4222 },
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
var value = server.ToString();
|
||||
|
||||
value.ShouldContain("NatsServer(");
|
||||
value.ShouldContain("Name=test-node");
|
||||
value.ShouldContain("Addr=127.0.0.1:4222");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortsInfo_returns_configured_listen_endpoints()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 4222,
|
||||
MonitorHost = "127.0.0.1",
|
||||
MonitorPort = 8222,
|
||||
ProfPort = 6060,
|
||||
WebSocket = new WebSocketOptions { Host = "127.0.0.1", Port = 8443 },
|
||||
Cluster = new ClusterOptions { Host = "127.0.0.1", Port = 6222 },
|
||||
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 7422 },
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
var ports = server.PortsInfo();
|
||||
|
||||
ports.Nats.ShouldContain("127.0.0.1:4222");
|
||||
ports.Monitoring.ShouldContain("127.0.0.1:8222");
|
||||
ports.Profile.ShouldContain("127.0.0.1:6060");
|
||||
ports.WebSocket.ShouldContain("127.0.0.1:8443");
|
||||
ports.Cluster.ShouldContain("127.0.0.1:6222");
|
||||
ports.LeafNodes.ShouldContain("127.0.0.1:7422");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Profiler_and_peer_accessors_have_parity_surface()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions { Port = 4222, ProfPort = 6060 },
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
server.StartProfiler().ShouldBeTrue();
|
||||
server.ActivePeers().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_urls_helpers_include_non_wildcard_and_cache_refresh()
|
||||
{
|
||||
using var server = new NatsServer(
|
||||
new NatsOptions { Host = "127.0.0.1", Port = 4222 },
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
var urls = server.GetConnectURLs();
|
||||
urls.ShouldContain("nats://127.0.0.1:4222");
|
||||
|
||||
server.UpdateServerINFOAndSendINFOToClients();
|
||||
var info = Encoding.ASCII.GetString(server.CachedInfoLine);
|
||||
info.ShouldContain("\"connect_urls\":[\"nats://127.0.0.1:4222\"]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectClientByID_closes_connected_client()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var server = new NatsServer(new NatsOptions { Host = "127.0.0.1", Port = port }, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
using var socket = await ConnectAndHandshakeAsync(port);
|
||||
await WaitUntilAsync(() => server.ClientCount == 1);
|
||||
var clientId = server.GetClients().Single().Id;
|
||||
|
||||
server.DisconnectClientByID(clientId).ShouldBeTrue();
|
||||
await WaitUntilAsync(() => server.ClientCount == 0);
|
||||
|
||||
await server.ShutdownAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LDMClientByID_closes_connected_client()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var server = new NatsServer(new NatsOptions { Host = "127.0.0.1", Port = port }, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
using var socket = await ConnectAndHandshakeAsync(port);
|
||||
await WaitUntilAsync(() => server.ClientCount == 1);
|
||||
var clientId = server.GetClients().Single().Id;
|
||||
|
||||
server.LDMClientByID(clientId).ShouldBeTrue();
|
||||
await WaitUntilAsync(() => server.ClientCount == 0);
|
||||
|
||||
await server.ShutdownAsync();
|
||||
}
|
||||
|
||||
private static async Task<Socket> ConnectAndHandshakeAsync(int port)
|
||||
{
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(IPAddress.Loopback, port);
|
||||
|
||||
_ = await ReadLineAsync(socket, CancellationToken.None); // INFO
|
||||
await socket.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"), SocketFlags.None);
|
||||
var pong = await ReadUntilContainsAsync(socket, "PONG", CancellationToken.None);
|
||||
pong.ShouldContain("PONG");
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var buffer = new List<byte>(256);
|
||||
var single = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var n = await socket.ReceiveAsync(single.AsMemory(0, 1), SocketFlags.None, ct);
|
||||
if (n == 0)
|
||||
break;
|
||||
|
||||
if (single[0] == '\n')
|
||||
break;
|
||||
|
||||
if (single[0] != '\r')
|
||||
buffer.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. buffer]);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadUntilContainsAsync(Socket socket, string token, CancellationToken ct)
|
||||
{
|
||||
var end = DateTime.UtcNow.AddSeconds(3);
|
||||
var builder = new StringBuilder();
|
||||
while (DateTime.UtcNow < end)
|
||||
{
|
||||
var line = await ReadLineAsync(socket, ct);
|
||||
if (line.Length == 0)
|
||||
continue;
|
||||
|
||||
builder.AppendLine(line);
|
||||
if (builder.ToString().Contains(token, StringComparison.Ordinal))
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> predicate)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
if (predicate())
|
||||
return;
|
||||
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
throw new TimeoutException("Condition was not met in time.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using NATS.Server.Server;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Server;
|
||||
|
||||
public class CoreServerOptionsParityBatch3Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Core_ports_and_compression_types_exist_with_expected_defaults()
|
||||
{
|
||||
var ports = new Ports
|
||||
{
|
||||
Nats = ["nats://127.0.0.1:4222"],
|
||||
Monitoring = ["http://127.0.0.1:8222"],
|
||||
};
|
||||
|
||||
ports.Nats.ShouldHaveSingleItem();
|
||||
ports.Monitoring.ShouldHaveSingleItem();
|
||||
|
||||
CompressionModes.Off.ShouldBe("off");
|
||||
CompressionModes.S2Auto.ShouldBe("s2_auto");
|
||||
|
||||
var opts = new CompressionOpts();
|
||||
opts.Mode.ShouldBe(CompressionModes.Off);
|
||||
opts.RTTThresholds.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoutesFromStr_parses_comma_delimited_routes()
|
||||
{
|
||||
var routes = NatsOptions.RoutesFromStr(" nats://a:6222, tls://b:7222 ");
|
||||
|
||||
routes.Count.ShouldBe(2);
|
||||
routes[0].ToString().ShouldBe("nats://a:6222/");
|
||||
routes[1].ToString().ShouldBe("tls://b:7222/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clone_returns_deep_copy_for_common_collections()
|
||||
{
|
||||
var original = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 4222,
|
||||
Tags = new Dictionary<string, string> { ["a"] = "1" },
|
||||
SubjectMappings = new Dictionary<string, string> { ["foo.*"] = "bar.$1" },
|
||||
TlsPinnedCerts = ["abc"],
|
||||
};
|
||||
original.InCmdLine.Add("host");
|
||||
|
||||
var clone = original.Clone();
|
||||
|
||||
clone.ShouldNotBeSameAs(original);
|
||||
clone.Tags.ShouldNotBeSameAs(original.Tags);
|
||||
clone.SubjectMappings.ShouldNotBeSameAs(original.SubjectMappings);
|
||||
clone.TlsPinnedCerts.ShouldNotBeSameAs(original.TlsPinnedCerts);
|
||||
clone.InCmdLine.ShouldNotBeSameAs(original.InCmdLine);
|
||||
clone.InCmdLine.ShouldContain("host");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessConfigString_sets_config_digest_and_applies_values()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.ProcessConfigString("port: 4333");
|
||||
|
||||
opts.Port.ShouldBe(4333);
|
||||
opts.ConfigDigest().ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoErrOnUnknownFields_toggle_is_available()
|
||||
{
|
||||
NatsOptions.NoErrOnUnknownFields(true);
|
||||
var ex = Record.Exception(() => new NatsOptions().ProcessConfigString("totally_unknown_field: 1"));
|
||||
ex.ShouldBeNull();
|
||||
NatsOptions.NoErrOnUnknownFields(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Option_parity_types_exist()
|
||||
{
|
||||
var jsLimits = new JSLimitOpts
|
||||
{
|
||||
MaxRequestBatch = 10,
|
||||
MaxAckPending = 20,
|
||||
};
|
||||
jsLimits.MaxRequestBatch.ShouldBe(10);
|
||||
jsLimits.MaxAckPending.ShouldBe(20);
|
||||
|
||||
var callout = new AuthCallout
|
||||
{
|
||||
Issuer = "issuer",
|
||||
Account = "A",
|
||||
AllowedAccounts = ["A", "B"],
|
||||
};
|
||||
callout.Issuer.ShouldBe("issuer");
|
||||
callout.AllowedAccounts.ShouldContain("B");
|
||||
|
||||
var proxies = new ProxiesConfig
|
||||
{
|
||||
Trusted = [new ProxyConfig { Key = "k1" }],
|
||||
};
|
||||
proxies.Trusted.Count.ShouldBe(1);
|
||||
proxies.Trusted[0].Key.ShouldBe("k1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Net.Sockets;
|
||||
using NATS.Server.Routes;
|
||||
using NATS.Server.Server;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Server;
|
||||
|
||||
public class UtilitiesAndRateCounterParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseHostPort_uses_default_port_for_missing_zero_and_minus_one()
|
||||
{
|
||||
ServerUtilities.ParseHostPort("127.0.0.1", 4222).ShouldBe(("127.0.0.1", 4222));
|
||||
ServerUtilities.ParseHostPort("127.0.0.1:0", 4222).ShouldBe(("127.0.0.1", 4222));
|
||||
ServerUtilities.ParseHostPort("127.0.0.1:-1", 4222).ShouldBe(("127.0.0.1", 4222));
|
||||
ServerUtilities.ParseHostPort(":4333", 4222).ShouldBe(("", 4333));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactUrl_helpers_redact_password_for_single_and_list_inputs()
|
||||
{
|
||||
ServerUtilities.RedactUrlString("nats://foo:bar@example.com:4222")
|
||||
.ShouldBe("nats://foo:xxxxx@example.com:4222");
|
||||
ServerUtilities.RedactUrlString("nats://example.com:4222")
|
||||
.ShouldBe("nats://example.com:4222");
|
||||
|
||||
var redacted = ServerUtilities.RedactUrlList(
|
||||
["nats://a:b@one:4222", "nats://noauth:4223"]);
|
||||
redacted[0].ShouldBe("nats://a:xxxxx@one:4222");
|
||||
redacted[1].ShouldBe("nats://noauth:4223");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RateCounter_allow_and_count_blocked_match_go_behavior()
|
||||
{
|
||||
var rc = new RateCounter(2);
|
||||
|
||||
rc.Allow().ShouldBeTrue();
|
||||
rc.Allow().ShouldBeTrue();
|
||||
rc.Allow().ShouldBeFalse();
|
||||
rc.Allow().ShouldBeFalse();
|
||||
|
||||
rc.CountBlocked().ShouldBe((ulong)2);
|
||||
rc.CountBlocked().ShouldBe((ulong)0); // reset on read
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateRouteDialSocket_disables_keepalive()
|
||||
{
|
||||
using var socket = RouteManager.CreateRouteDialSocket();
|
||||
var keepAlive = Convert.ToInt32(socket.GetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive));
|
||||
keepAlive.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using NATS.Server.Server;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Server;
|
||||
|
||||
public class UtilitiesErrorConstantsParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Error_constants_match_go_error_literals_batch2()
|
||||
{
|
||||
ServerErrorConstants.ErrBadQualifier.ShouldBe("bad qualifier");
|
||||
ServerErrorConstants.ErrTooManyAccountConnections.ShouldBe("maximum account active connections exceeded");
|
||||
ServerErrorConstants.ErrTooManySubs.ShouldBe("maximum subscriptions exceeded");
|
||||
ServerErrorConstants.ErrTooManySubTokens.ShouldBe("subject has exceeded number of tokens limit");
|
||||
ServerErrorConstants.ErrReservedAccount.ShouldBe("reserved account");
|
||||
ServerErrorConstants.ErrMissingService.ShouldBe("service missing");
|
||||
ServerErrorConstants.ErrBadServiceType.ShouldBe("bad service response type");
|
||||
ServerErrorConstants.ErrBadSampling.ShouldBe("bad sampling percentage, should be 1-100");
|
||||
ServerErrorConstants.ErrAccountResolverUpdateTooSoon.ShouldBe("account resolver update too soon");
|
||||
ServerErrorConstants.ErrAccountResolverSameClaims.ShouldBe("account resolver no new claims");
|
||||
ServerErrorConstants.ErrStreamImportAuthorization.ShouldBe("stream import not authorized");
|
||||
ServerErrorConstants.ErrStreamImportBadPrefix.ShouldBe("stream import prefix can not contain wildcard tokens");
|
||||
ServerErrorConstants.ErrStreamImportDuplicate.ShouldBe("stream import already exists");
|
||||
ServerErrorConstants.ErrServiceImportAuthorization.ShouldBe("service import not authorized");
|
||||
ServerErrorConstants.ErrImportFormsCycle.ShouldBe("import forms a cycle");
|
||||
ServerErrorConstants.ErrCycleSearchDepth.ShouldBe("search cycle depth exhausted");
|
||||
ServerErrorConstants.ErrNoTransforms.ShouldBe("no matching transforms available");
|
||||
}
|
||||
}
|
||||
139
tests/NATS.Server.Core.Tests/ServerConfigTests.cs
Normal file
139
tests/NATS.Server.Core.Tests/ServerConfigTests.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
// Tests ported from Go server_test.go:
|
||||
// TestRandomPorts, TestInfoServerNameDefaultsToPK, TestInfoServerNameIsSettable,
|
||||
// TestLameDuckModeInfo (simplified — no cluster, just ldm property/state)
|
||||
public class ServerConfigTests
|
||||
{
|
||||
|
||||
|
||||
// Ref: golang/nats-server/server/server_test.go TestRandomPorts
|
||||
// The Go test uses Port=-1 (their sentinel for "random"), we use Port=0 (.NET/BSD standard).
|
||||
// Verifies that after startup, server.Port is resolved to a non-zero ephemeral port.
|
||||
[Fact]
|
||||
public async Task Server_resolves_ephemeral_port_when_zero()
|
||||
{
|
||||
var opts = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
server.Port.ShouldBeGreaterThan(0);
|
||||
server.Port.ShouldNotBe(4222);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// Ref: golang/nats-server/server/server_test.go TestInfoServerNameIsSettable
|
||||
// Verifies that ServerName set in options is reflected in both the server property
|
||||
// and the INFO line sent to connecting clients.
|
||||
[Fact]
|
||||
public async Task Server_info_contains_server_name()
|
||||
{
|
||||
const string name = "my-test-server";
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
var opts = new NatsOptions { Port = port, ServerName = name };
|
||||
using var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Property check
|
||||
server.ServerName.ShouldBe(name);
|
||||
|
||||
// Wire check — INFO line sent on connect
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||||
var infoLine = await SocketTestHelper.ReadUntilAsync(sock, "INFO");
|
||||
infoLine.ShouldContain("\"server_name\":\"my-test-server\"");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// Ref: golang/nats-server/server/server_test.go TestInfoServerNameDefaultsToPK
|
||||
// Verifies that when no ServerName is configured, the server still populates both
|
||||
// server_id and server_name fields in the INFO line (name defaults to a generated value,
|
||||
// not null or empty).
|
||||
[Fact]
|
||||
public async Task Server_info_defaults_name_when_not_configured()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
var opts = new NatsOptions { Port = port }; // no ServerName set
|
||||
using var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Both properties should be populated
|
||||
server.ServerId.ShouldNotBeNullOrWhiteSpace();
|
||||
server.ServerName.ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
// Wire check — INFO line includes both fields
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||||
var infoLine = await SocketTestHelper.ReadUntilAsync(sock, "INFO");
|
||||
infoLine.ShouldContain("\"server_id\":");
|
||||
infoLine.ShouldContain("\"server_name\":");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// Ref: golang/nats-server/server/server_test.go TestLameDuckModeInfo
|
||||
// Simplified port: verifies that LameDuckShutdownAsync transitions the server into
|
||||
// lame duck mode (IsLameDuckMode becomes true) and that the server ultimately shuts
|
||||
// down. The full Go test requires a cluster to observe INFO updates with "ldm":true;
|
||||
// that aspect is not ported here because the .NET ServerInfo type does not include
|
||||
// an ldm/LameDuckMode field and cluster routing is out of scope for this test.
|
||||
[Fact]
|
||||
public async Task Lame_duck_mode_sets_is_lame_duck_mode_and_shuts_down()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
Port = port,
|
||||
LameDuckGracePeriod = TimeSpan.Zero,
|
||||
LameDuckDuration = TimeSpan.FromMilliseconds(50),
|
||||
};
|
||||
using var server = new NatsServer(opts, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
server.IsLameDuckMode.ShouldBeFalse();
|
||||
|
||||
// Trigger lame duck — no clients connected so it should proceed straight to shutdown.
|
||||
await server.LameDuckShutdownAsync();
|
||||
|
||||
server.IsLameDuckMode.ShouldBeTrue();
|
||||
server.IsShuttingDown.ShouldBeTrue();
|
||||
|
||||
await cts.CancelAsync();
|
||||
}
|
||||
}
|
||||
100
tests/NATS.Server.Core.Tests/ServerStatsTests.cs
Normal file
100
tests/NATS.Server.Core.Tests/ServerStatsTests.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ServerStatsTests : IAsyncLifetime
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public ServerStatsTests()
|
||||
{
|
||||
_port = TestPortAllocator.GetFreePort();
|
||||
_server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_ = _server.StartAsync(_cts.Token);
|
||||
await _server.WaitForReadyAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_server.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_has_start_time()
|
||||
{
|
||||
_server.StartTime.ShouldNotBe(default);
|
||||
_server.StartTime.ShouldBeLessThanOrEqualTo(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_tracks_total_connections()
|
||||
{
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _port));
|
||||
await Task.Delay(100);
|
||||
_server.Stats.TotalConnections.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_stats_track_messages()
|
||||
{
|
||||
using var pub = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await pub.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _port));
|
||||
|
||||
var buf = new byte[4096];
|
||||
await pub.ReceiveAsync(buf, SocketFlags.None); // INFO
|
||||
|
||||
await pub.SendAsync("CONNECT {}\r\nSUB test 1\r\nPUB test 5\r\nhello\r\n"u8.ToArray());
|
||||
await Task.Delay(200);
|
||||
|
||||
_server.Stats.InMsgs.ShouldBeGreaterThanOrEqualTo(1);
|
||||
_server.Stats.InBytes.ShouldBeGreaterThanOrEqualTo(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_has_metadata()
|
||||
{
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _port));
|
||||
await Task.Delay(100);
|
||||
|
||||
var client = _server.GetClients().First();
|
||||
client.RemoteIp.ShouldNotBeNullOrEmpty();
|
||||
client.RemotePort.ShouldBeGreaterThan(0);
|
||||
client.StartTime.ShouldNotBe(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleConnection_stats_incremented_on_mark_closed()
|
||||
{
|
||||
var stats = new ServerStats();
|
||||
stats.StaleConnectionClients.ShouldBe(0);
|
||||
|
||||
Interlocked.Increment(ref stats.StaleConnectionClients);
|
||||
stats.StaleConnectionClients.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleConnection_stats_all_fields_default_to_zero()
|
||||
{
|
||||
var stats = new ServerStats();
|
||||
stats.StaleConnections.ShouldBe(0);
|
||||
stats.StaleConnectionClients.ShouldBe(0);
|
||||
stats.StaleConnectionRoutes.ShouldBe(0);
|
||||
stats.StaleConnectionLeafs.ShouldBe(0);
|
||||
stats.StaleConnectionGateways.ShouldBe(0);
|
||||
}
|
||||
|
||||
}
|
||||
817
tests/NATS.Server.Core.Tests/ServerTests.cs
Normal file
817
tests/NATS.Server.Core.Tests/ServerTests.cs
Normal file
@@ -0,0 +1,817 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class ServerTests : IAsyncLifetime
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public ServerTests()
|
||||
{
|
||||
// Use random port
|
||||
_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<Socket> ConnectClientAsync()
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, _port);
|
||||
return sock;
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket sock, int bufSize = 4096)
|
||||
{
|
||||
var buf = new byte[bufSize];
|
||||
var n = await sock.ReceiveAsync(buf, SocketFlags.None);
|
||||
return Encoding.ASCII.GetString(buf, 0, n);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads from a socket until the accumulated data contains the expected substring.
|
||||
/// </summary>
|
||||
|
||||
[Fact]
|
||||
public async Task Server_accepts_connection_and_sends_INFO()
|
||||
{
|
||||
using var client = await ConnectClientAsync();
|
||||
var response = await ReadLineAsync(client);
|
||||
|
||||
response.ShouldStartWith("INFO ");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_basic_pubsub()
|
||||
{
|
||||
using var pub = await ConnectClientAsync();
|
||||
using var sub = await ConnectClientAsync();
|
||||
|
||||
// Read INFO from both
|
||||
await ReadLineAsync(pub);
|
||||
await ReadLineAsync(sub);
|
||||
|
||||
// CONNECT + SUB on subscriber, then PING to flush
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo 1\r\nPING\r\n"));
|
||||
var pong = await ReadLineAsync(sub);
|
||||
pong.ShouldContain("PONG");
|
||||
|
||||
// CONNECT + PUB on publisher
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPUB foo 5\r\nHello\r\n"));
|
||||
|
||||
// Read MSG from subscriber (may arrive across multiple TCP segments)
|
||||
var msg = await SocketTestHelper.ReadUntilAsync(sub, "Hello\r\n");
|
||||
msg.ShouldContain("MSG foo 1 5\r\nHello\r\n");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_wildcard_matching()
|
||||
{
|
||||
using var pub = await ConnectClientAsync();
|
||||
using var sub = await ConnectClientAsync();
|
||||
|
||||
await ReadLineAsync(pub);
|
||||
await ReadLineAsync(sub);
|
||||
|
||||
// CONNECT + SUB on subscriber, then PING to flush
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo.* 1\r\nPING\r\n"));
|
||||
var pong = await ReadLineAsync(sub);
|
||||
pong.ShouldContain("PONG");
|
||||
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPUB foo.bar 5\r\nHello\r\n"));
|
||||
|
||||
var buf = new byte[4096];
|
||||
var n = await sub.ReceiveAsync(buf, SocketFlags.None);
|
||||
var msg = Encoding.ASCII.GetString(buf, 0, n);
|
||||
|
||||
msg.ShouldContain("MSG foo.bar 1 5\r\n");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_pedantic_rejects_invalid_publish_subject()
|
||||
{
|
||||
using var pub = await ConnectClientAsync();
|
||||
using var sub = await ConnectClientAsync();
|
||||
|
||||
// Read INFO from both
|
||||
await ReadLineAsync(pub);
|
||||
await ReadLineAsync(sub);
|
||||
|
||||
// Connect with pedantic mode ON
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(
|
||||
"CONNECT {\"pedantic\":true}\r\nPING\r\n"));
|
||||
var pong = await SocketTestHelper.ReadUntilAsync(pub, "PONG");
|
||||
|
||||
// Subscribe on sub
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo.* 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
|
||||
|
||||
// PUB with wildcard subject (invalid for publish)
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PUB foo.* 5\r\nHello\r\n"));
|
||||
|
||||
// Publisher should get -ERR
|
||||
var errResponse = await SocketTestHelper.ReadUntilAsync(pub, "-ERR", timeoutMs: 3000);
|
||||
errResponse.ShouldContain("-ERR 'Invalid Publish Subject'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_nonpedantic_allows_wildcard_publish_subject()
|
||||
{
|
||||
using var pub = await ConnectClientAsync();
|
||||
using var sub = await ConnectClientAsync();
|
||||
|
||||
await ReadLineAsync(pub);
|
||||
await ReadLineAsync(sub);
|
||||
|
||||
// Connect without pedantic mode (default)
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo.* 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
|
||||
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPUB foo.* 5\r\nHello\r\n"));
|
||||
|
||||
// Sub should still receive the message (no validation in non-pedantic mode)
|
||||
var msg = await SocketTestHelper.ReadUntilAsync(sub, "Hello\r\n");
|
||||
msg.ShouldContain("MSG foo.* 1 5\r\nHello\r\n");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_rejects_max_payload_violation()
|
||||
{
|
||||
// Create server with tiny max payload
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(new NatsOptions { Port = port, MaxPayload = 10 }, NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client.ConnectAsync(IPAddress.Loopback, port);
|
||||
|
||||
var buf = new byte[4096];
|
||||
await client.ReceiveAsync(buf, SocketFlags.None); // INFO
|
||||
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n"));
|
||||
|
||||
// Send PUB with payload larger than MaxPayload (10 bytes)
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("PUB foo 20\r\n12345678901234567890\r\n"));
|
||||
|
||||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var n = await client.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
var response = Encoding.ASCII.GetString(buf, 0, n);
|
||||
response.ShouldContain("-ERR 'Maximum Payload Violation'");
|
||||
|
||||
// Connection should be closed
|
||||
n = await client.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
n.ShouldBe(0);
|
||||
|
||||
client.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class EphemeralPortTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Server_resolves_ephemeral_port()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(new NatsOptions { Port = 0 }, NullLoggerFactory.Instance);
|
||||
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Port should have been resolved to a real port
|
||||
server.Port.ShouldBeGreaterThan(0);
|
||||
|
||||
// Connect a raw socket to prove the port actually works
|
||||
using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client.ConnectAsync(IPAddress.Loopback, server.Port);
|
||||
|
||||
var buf = new byte[4096];
|
||||
var n = await client.ReceiveAsync(buf, SocketFlags.None);
|
||||
var response = Encoding.ASCII.GetString(buf, 0, n);
|
||||
response.ShouldStartWith("INFO ");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class MaxConnectionsTests : IAsyncLifetime
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public MaxConnectionsTests()
|
||||
{
|
||||
_port = TestPortAllocator.GetFreePort();
|
||||
_server = new NatsServer(new NatsOptions { Port = _port, MaxConnections = 2 }, NullLoggerFactory.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_ = _server.StartAsync(_cts.Token);
|
||||
await _server.WaitForReadyAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
_server.Dispose();
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Server_rejects_connection_when_max_reached()
|
||||
{
|
||||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Connect two clients (at limit)
|
||||
var client1 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client1.ConnectAsync(IPAddress.Loopback, _port);
|
||||
var buf = new byte[4096];
|
||||
var n = await client1.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
Encoding.ASCII.GetString(buf, 0, n).ShouldStartWith("INFO ");
|
||||
|
||||
var client2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client2.ConnectAsync(IPAddress.Loopback, _port);
|
||||
n = await client2.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
Encoding.ASCII.GetString(buf, 0, n).ShouldStartWith("INFO ");
|
||||
|
||||
// Third client should be rejected
|
||||
var client3 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client3.ConnectAsync(IPAddress.Loopback, _port);
|
||||
|
||||
n = await client3.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
var response = Encoding.ASCII.GetString(buf, 0, n);
|
||||
response.ShouldContain("-ERR 'maximum connections exceeded'");
|
||||
|
||||
// Connection should be closed
|
||||
n = await client3.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
n.ShouldBe(0);
|
||||
|
||||
client1.Dispose();
|
||||
client2.Dispose();
|
||||
client3.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public class PingKeepaliveTests : IAsyncLifetime
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public PingKeepaliveTests()
|
||||
{
|
||||
_port = TestPortAllocator.GetFreePort();
|
||||
// Short intervals for testing: 500ms ping interval, 2 max pings out
|
||||
_server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
Port = _port,
|
||||
PingInterval = TimeSpan.FromMilliseconds(500),
|
||||
MaxPingsOut = 2,
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_ = _server.StartAsync(_cts.Token);
|
||||
await _server.WaitForReadyAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
_server.Dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Server_sends_PING_after_inactivity()
|
||||
{
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client.ConnectAsync(IPAddress.Loopback, _port);
|
||||
|
||||
// Read INFO
|
||||
var buf = new byte[4096];
|
||||
await client.ReceiveAsync(buf, SocketFlags.None);
|
||||
|
||||
// Send CONNECT to start keepalive
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n"));
|
||||
|
||||
// Wait for server to send PING (should come within ~500ms)
|
||||
var response = await SocketTestHelper.ReadUntilAsync(client, "PING", timeoutMs: 3000);
|
||||
response.ShouldContain("PING");
|
||||
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_pong_resets_ping_counter()
|
||||
{
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client.ConnectAsync(IPAddress.Loopback, _port);
|
||||
|
||||
var buf = new byte[4096];
|
||||
await client.ReceiveAsync(buf, SocketFlags.None); // INFO
|
||||
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n"));
|
||||
|
||||
// Wait for first PING
|
||||
var response = await SocketTestHelper.ReadUntilAsync(client, "PING", timeoutMs: 3000);
|
||||
response.ShouldContain("PING");
|
||||
|
||||
// Respond with PONG — this resets the counter
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("PONG\r\n"));
|
||||
|
||||
// Wait for next PING (counter reset, so we should get another one)
|
||||
response = await SocketTestHelper.ReadUntilAsync(client, "PING", timeoutMs: 3000);
|
||||
response.ShouldContain("PING");
|
||||
|
||||
// Respond again to keep alive
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("PONG\r\n"));
|
||||
|
||||
// Client should still be alive — send a PING and expect PONG back
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
response = await SocketTestHelper.ReadUntilAsync(client, "PONG", timeoutMs: 3000);
|
||||
response.ShouldContain("PONG");
|
||||
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_disconnects_stale_client()
|
||||
{
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client.ConnectAsync(IPAddress.Loopback, _port);
|
||||
|
||||
var buf = new byte[4096];
|
||||
await client.ReceiveAsync(buf, SocketFlags.None); // INFO
|
||||
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n"));
|
||||
|
||||
// Don't respond to PINGs — wait for stale disconnect
|
||||
// With 500ms interval and MaxPingsOut=2:
|
||||
// t=500ms: PING #1, pingsOut=1
|
||||
// t=1000ms: PING #2, pingsOut=2
|
||||
// t=1500ms: pingsOut+1 > MaxPingsOut → -ERR 'Stale Connection' + close
|
||||
var sb = new StringBuilder();
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (true)
|
||||
{
|
||||
var n = await client.ReceiveAsync(buf, SocketFlags.None, timeout.Token);
|
||||
if (n == 0) break;
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Timeout is acceptable — check what we got
|
||||
}
|
||||
|
||||
var allData = sb.ToString();
|
||||
allData.ShouldContain("-ERR 'Stale Connection'");
|
||||
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public class CloseReasonTests : IAsyncLifetime
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
public CloseReasonTests()
|
||||
{
|
||||
_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();
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Client_close_reason_set_on_normal_disconnect()
|
||||
{
|
||||
// Connect a raw TCP client
|
||||
using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client.ConnectAsync(IPAddress.Loopback, _port);
|
||||
|
||||
// Read INFO
|
||||
var buf = new byte[4096];
|
||||
await client.ReceiveAsync(buf, SocketFlags.None);
|
||||
|
||||
// Send CONNECT + PING, wait for PONG
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
|
||||
var sb = new StringBuilder();
|
||||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!sb.ToString().Contains("PONG"))
|
||||
{
|
||||
var n = await client.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
if (n == 0) break;
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
sb.ToString().ShouldContain("PONG");
|
||||
|
||||
// Get the NatsClient from the server
|
||||
var natsClient = _server.GetClients().First();
|
||||
|
||||
// Close the TCP socket (normal client disconnect)
|
||||
client.Shutdown(SocketShutdown.Both);
|
||||
client.Close();
|
||||
|
||||
// Wait for the server to detect the disconnect
|
||||
await Task.Delay(500);
|
||||
|
||||
// The close reason should be ClientClosed (normal disconnect falls through to finally)
|
||||
natsClient.CloseReason.ShouldBe(ClientClosedReason.ClientClosed);
|
||||
}
|
||||
}
|
||||
|
||||
public class ServerIdentityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Server_creates_system_account()
|
||||
{
|
||||
var server = new NatsServer(new NatsOptions { Port = 0 }, NullLoggerFactory.Instance);
|
||||
server.SystemAccount.ShouldNotBeNull();
|
||||
server.SystemAccount.Name.ShouldBe("$SYS");
|
||||
server.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_generates_nkey_identity()
|
||||
{
|
||||
var server = new NatsServer(new NatsOptions { Port = 0 }, NullLoggerFactory.Instance);
|
||||
server.ServerNKey.ShouldNotBeNullOrEmpty();
|
||||
// Server NKey public keys start with 'N'
|
||||
server.ServerNKey[0].ShouldBe('N');
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public class FlushBeforeCloseTests
|
||||
{
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Shutdown_flushes_pending_data_to_clients()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(CancellationToken.None);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Connect a subscriber via raw socket
|
||||
using var sub = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sub.ConnectAsync(IPAddress.Loopback, port);
|
||||
|
||||
// Read INFO
|
||||
var buf = new byte[4096];
|
||||
await sub.ReceiveAsync(buf, SocketFlags.None);
|
||||
|
||||
// Subscribe to "foo"
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo 1\r\nPING\r\n"));
|
||||
var pong = await SocketTestHelper.ReadUntilAsync(sub, "PONG");
|
||||
pong.ShouldContain("PONG");
|
||||
|
||||
// Connect a publisher
|
||||
using var pub = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await pub.ConnectAsync(IPAddress.Loopback, port);
|
||||
await pub.ReceiveAsync(buf, SocketFlags.None); // INFO
|
||||
|
||||
// Publish "Hello" to "foo"
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPUB foo 5\r\nHello\r\n"));
|
||||
|
||||
// Wait briefly for delivery
|
||||
await Task.Delay(200);
|
||||
|
||||
// Read from subscriber to verify MSG was received
|
||||
var msg = await SocketTestHelper.ReadUntilAsync(sub, "Hello\r\n");
|
||||
msg.ShouldContain("MSG foo 1 5\r\nHello\r\n");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await server.ShutdownAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class GracefulShutdownTests
|
||||
{
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_disconnects_all_clients()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(CancellationToken.None);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
// Connect 2 raw TCP clients
|
||||
using var client1 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client1.ConnectAsync(IPAddress.Loopback, port);
|
||||
var buf = new byte[4096];
|
||||
await client1.ReceiveAsync(buf, SocketFlags.None); // INFO
|
||||
|
||||
using var client2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client2.ConnectAsync(IPAddress.Loopback, port);
|
||||
await client2.ReceiveAsync(buf, SocketFlags.None); // INFO
|
||||
|
||||
// Send CONNECT so both are registered
|
||||
await client1.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
|
||||
await client2.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
|
||||
|
||||
// Wait for PONG from both (confirming they are registered)
|
||||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await client1.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
await client2.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
|
||||
|
||||
server.ClientCount.ShouldBe(2);
|
||||
|
||||
await server.ShutdownAsync();
|
||||
|
||||
server.ClientCount.ShouldBe(0);
|
||||
server.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForShutdown_blocks_until_shutdown()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(CancellationToken.None);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
// Start WaitForShutdown in background
|
||||
var waitTask = Task.Run(() => server.WaitForShutdown());
|
||||
|
||||
// Give it a moment -- it should NOT complete yet
|
||||
await Task.Delay(200);
|
||||
waitTask.IsCompleted.ShouldBeFalse();
|
||||
|
||||
// Trigger shutdown
|
||||
await server.ShutdownAsync();
|
||||
|
||||
// WaitForShutdown should complete within 5 seconds
|
||||
var completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||
completed.ShouldBe(waitTask);
|
||||
|
||||
server.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_is_idempotent()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(CancellationToken.None);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
// Call ShutdownAsync 3 times -- should not throw
|
||||
await server.ShutdownAsync();
|
||||
await server.ShutdownAsync();
|
||||
await server.ShutdownAsync();
|
||||
|
||||
server.IsShuttingDown.ShouldBeTrue();
|
||||
server.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accept_loop_waits_for_active_clients()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(CancellationToken.None);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
// Connect a client
|
||||
using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client.ConnectAsync(IPAddress.Loopback, port);
|
||||
var buf = new byte[4096];
|
||||
await client.ReceiveAsync(buf, SocketFlags.None); // INFO
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
|
||||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await client.ReceiveAsync(buf, SocketFlags.None, readCts.Token); // PONG
|
||||
|
||||
// ShutdownAsync should complete within 10 seconds (doesn't hang)
|
||||
var shutdownTask = server.ShutdownAsync();
|
||||
var completed = await Task.WhenAny(shutdownTask, Task.Delay(TimeSpan.FromSeconds(10)));
|
||||
completed.ShouldBe(shutdownTask);
|
||||
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public class LameDuckTests
|
||||
{
|
||||
|
||||
[Fact]
|
||||
public async Task LameDuckShutdown_stops_accepting_new_connections()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
Port = port,
|
||||
LameDuckDuration = TimeSpan.FromSeconds(3),
|
||||
LameDuckGracePeriod = TimeSpan.FromMilliseconds(500),
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
_ = server.StartAsync(CancellationToken.None);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Connect 1 client
|
||||
using var client1 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await client1.ConnectAsync(IPAddress.Loopback, port);
|
||||
var buf = new byte[4096];
|
||||
await client1.ReceiveAsync(buf, SocketFlags.None); // INFO
|
||||
await client1.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
|
||||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await client1.ReceiveAsync(buf, SocketFlags.None, readCts.Token); // PONG
|
||||
|
||||
// Start lame duck (don't await yet)
|
||||
var lameDuckTask = server.LameDuckShutdownAsync();
|
||||
|
||||
// Wait briefly for listener to close
|
||||
await Task.Delay(300);
|
||||
|
||||
// Verify lame duck mode is active
|
||||
server.IsLameDuckMode.ShouldBeTrue();
|
||||
|
||||
// Try connecting a new client -- should fail (connection refused)
|
||||
using var client2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
var connectAction = async () =>
|
||||
{
|
||||
await client2.ConnectAsync(IPAddress.Loopback, port);
|
||||
};
|
||||
await connectAction.ShouldThrowAsync<SocketException>();
|
||||
|
||||
// Await the lame duck task with timeout
|
||||
var completed = await Task.WhenAny(lameDuckTask, Task.Delay(TimeSpan.FromSeconds(15)));
|
||||
completed.ShouldBe(lameDuckTask);
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LameDuckShutdown_eventually_closes_all_clients()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions
|
||||
{
|
||||
Port = port,
|
||||
LameDuckDuration = TimeSpan.FromSeconds(2),
|
||||
LameDuckGracePeriod = TimeSpan.FromMilliseconds(200),
|
||||
},
|
||||
NullLoggerFactory.Instance);
|
||||
|
||||
_ = server.StartAsync(CancellationToken.None);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Connect 3 clients via raw sockets
|
||||
var clients = new List<Socket>();
|
||||
var buf = new byte[4096];
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||||
await sock.ReceiveAsync(buf, SocketFlags.None); // INFO
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
|
||||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await sock.ReceiveAsync(buf, SocketFlags.None, readCts.Token); // PONG
|
||||
clients.Add(sock);
|
||||
}
|
||||
|
||||
server.ClientCount.ShouldBe(3);
|
||||
|
||||
// Await LameDuckShutdownAsync
|
||||
var lameDuckTask = server.LameDuckShutdownAsync();
|
||||
var completed = await Task.WhenAny(lameDuckTask, Task.Delay(TimeSpan.FromSeconds(15)));
|
||||
completed.ShouldBe(lameDuckTask);
|
||||
|
||||
server.ClientCount.ShouldBe(0);
|
||||
|
||||
foreach (var sock in clients)
|
||||
sock.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class PidFileTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"nats-test-{Guid.NewGuid():N}");
|
||||
|
||||
public PidFileTests() => Directory.CreateDirectory(_tempDir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Server_writes_pid_file_on_startup()
|
||||
{
|
||||
var pidFile = Path.Combine(_tempDir, "nats.pid");
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
var server = new NatsServer(new NatsOptions { Port = port, PidFile = pidFile }, NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(CancellationToken.None);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
File.Exists(pidFile).ShouldBeTrue();
|
||||
var content = await File.ReadAllTextAsync(pidFile);
|
||||
int.Parse(content).ShouldBe(Environment.ProcessId);
|
||||
|
||||
await server.ShutdownAsync();
|
||||
File.Exists(pidFile).ShouldBeFalse();
|
||||
|
||||
server.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_writes_ports_file_on_startup()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
var server = new NatsServer(new NatsOptions { Port = port, PortsFileDir = _tempDir }, NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(CancellationToken.None);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
var portsFiles = Directory.GetFiles(_tempDir, "*.ports");
|
||||
portsFiles.Length.ShouldBe(1);
|
||||
|
||||
var content = await File.ReadAllTextAsync(portsFiles[0]);
|
||||
content.ShouldContain($"\"client\":{port}");
|
||||
|
||||
await server.ShutdownAsync();
|
||||
Directory.GetFiles(_tempDir, "*.ports").Length.ShouldBe(0);
|
||||
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
61
tests/NATS.Server.Core.Tests/SignalHandlerTests.cs
Normal file
61
tests/NATS.Server.Core.Tests/SignalHandlerTests.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
// Go reference: server/signal_unix.go (handleSignals), server/reload.go (Reload)
|
||||
|
||||
public class SignalHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public void SignalHandler_registers_without_throwing()
|
||||
{
|
||||
// Go reference: server/signal_unix.go — registration should succeed
|
||||
Should.NotThrow(() => SignalHandler.Register(() => { }));
|
||||
SignalHandler.IsRegistered.ShouldBeTrue();
|
||||
SignalHandler.Unregister();
|
||||
SignalHandler.IsRegistered.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigReloader_ReloadFromOptionsAsync_applies_reloadable_changes()
|
||||
{
|
||||
// Go reference: server/reload.go — reloadable options (e.g., MaxPayload) pass validation
|
||||
var original = new NatsOptions { Port = 4222, MaxPayload = 1024 };
|
||||
var updated = new NatsOptions { Port = 4222, MaxPayload = 2048 }; // MaxPayload is reloadable
|
||||
|
||||
var result = await ConfigReloader.ReloadFromOptionsAsync(original, updated);
|
||||
result.Success.ShouldBeTrue();
|
||||
result.RejectedChanges.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigReloader_rejects_non_reloadable_changes()
|
||||
{
|
||||
// Go reference: server/reload.go — Port change is NOT reloadable
|
||||
var original = new NatsOptions { Port = 4222 };
|
||||
var updated = new NatsOptions { Port = 5555 }; // port change is NOT reloadable
|
||||
|
||||
var result = await ConfigReloader.ReloadFromOptionsAsync(original, updated);
|
||||
result.RejectedChanges.ShouldContain(c => c.Contains("Port"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigReloader_identical_options_succeeds()
|
||||
{
|
||||
// Go reference: server/reload.go — no changes = success
|
||||
var original = new NatsOptions { Port = 4222 };
|
||||
var updated = new NatsOptions { Port = 4222 };
|
||||
|
||||
var result = await ConfigReloader.ReloadFromOptionsAsync(original, updated);
|
||||
result.Success.ShouldBeTrue();
|
||||
result.RejectedChanges.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalHandler_unregister_is_idempotent()
|
||||
{
|
||||
// Calling Unregister when not registered should not throw
|
||||
Should.NotThrow(() => SignalHandler.Unregister());
|
||||
Should.NotThrow(() => SignalHandler.Unregister());
|
||||
}
|
||||
}
|
||||
12
tests/NATS.Server.Core.Tests/SlopwatchSuppressAttribute.cs
Normal file
12
tests/NATS.Server.Core.Tests/SlopwatchSuppressAttribute.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
// Marker attribute recognised by the slopwatch static-analysis tool.
|
||||
// Apply to a test method to suppress a specific slopwatch rule violation.
|
||||
// The justification must be 20+ characters explaining why the suppression is intentional.
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
|
||||
public sealed class SlopwatchSuppressAttribute(string ruleId, string justification) : Attribute
|
||||
{
|
||||
public string RuleId { get; } = ruleId;
|
||||
public string Justification { get; } = justification;
|
||||
}
|
||||
153
tests/NATS.Server.Core.Tests/SlowConsumerStallGateTests.cs
Normal file
153
tests/NATS.Server.Core.Tests/SlowConsumerStallGateTests.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SlowConsumerTracker"/> and <see cref="Account"/> slow-consumer counters.
|
||||
/// Go reference: server/client.go — handleSlowConsumer, markConnAsSlow.
|
||||
/// </summary>
|
||||
public class SlowConsumerStallGateTests
|
||||
{
|
||||
// ── SlowConsumerTracker ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RecordSlowConsumer_increments_total_count()
|
||||
{
|
||||
var tracker = new SlowConsumerTracker();
|
||||
|
||||
tracker.RecordSlowConsumer(ClientKind.Client);
|
||||
tracker.RecordSlowConsumer(ClientKind.Client);
|
||||
tracker.RecordSlowConsumer(ClientKind.Router);
|
||||
|
||||
tracker.TotalCount.ShouldBe(3L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordSlowConsumer_increments_per_kind_count()
|
||||
{
|
||||
var tracker = new SlowConsumerTracker();
|
||||
|
||||
tracker.RecordSlowConsumer(ClientKind.Client);
|
||||
tracker.RecordSlowConsumer(ClientKind.Client);
|
||||
tracker.RecordSlowConsumer(ClientKind.Gateway);
|
||||
|
||||
tracker.GetCount(ClientKind.Client).ShouldBe(2L);
|
||||
tracker.GetCount(ClientKind.Gateway).ShouldBe(1L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCount_returns_zero_for_unrecorded_kind()
|
||||
{
|
||||
var tracker = new SlowConsumerTracker();
|
||||
|
||||
tracker.GetCount(ClientKind.Leaf).ShouldBe(0L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_kinds_tracked_independently()
|
||||
{
|
||||
var tracker = new SlowConsumerTracker();
|
||||
|
||||
tracker.RecordSlowConsumer(ClientKind.Client);
|
||||
tracker.RecordSlowConsumer(ClientKind.Router);
|
||||
tracker.RecordSlowConsumer(ClientKind.Gateway);
|
||||
tracker.RecordSlowConsumer(ClientKind.Leaf);
|
||||
|
||||
tracker.GetCount(ClientKind.Client).ShouldBe(1L);
|
||||
tracker.GetCount(ClientKind.Router).ShouldBe(1L);
|
||||
tracker.GetCount(ClientKind.Gateway).ShouldBe(1L);
|
||||
tracker.GetCount(ClientKind.Leaf).ShouldBe(1L);
|
||||
tracker.GetCount(ClientKind.System).ShouldBe(0L);
|
||||
tracker.TotalCount.ShouldBe(4L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnThresholdExceeded_fires_when_threshold_reached()
|
||||
{
|
||||
var tracker = new SlowConsumerTracker(threshold: 3);
|
||||
ClientKind? firedKind = null;
|
||||
tracker.OnThresholdExceeded(k => firedKind = k);
|
||||
|
||||
tracker.RecordSlowConsumer(ClientKind.Client);
|
||||
firedKind.ShouldBeNull();
|
||||
|
||||
tracker.RecordSlowConsumer(ClientKind.Client);
|
||||
firedKind.ShouldBeNull();
|
||||
|
||||
tracker.RecordSlowConsumer(ClientKind.Router);
|
||||
firedKind.ShouldBe(ClientKind.Router);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnThresholdExceeded_not_fired_below_threshold()
|
||||
{
|
||||
var tracker = new SlowConsumerTracker(threshold: 5);
|
||||
var fired = false;
|
||||
tracker.OnThresholdExceeded(_ => fired = true);
|
||||
|
||||
tracker.RecordSlowConsumer(ClientKind.Client);
|
||||
tracker.RecordSlowConsumer(ClientKind.Client);
|
||||
tracker.RecordSlowConsumer(ClientKind.Client);
|
||||
|
||||
fired.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_clears_all_counters()
|
||||
{
|
||||
var tracker = new SlowConsumerTracker();
|
||||
|
||||
tracker.RecordSlowConsumer(ClientKind.Client);
|
||||
tracker.RecordSlowConsumer(ClientKind.Router);
|
||||
tracker.TotalCount.ShouldBe(2L);
|
||||
|
||||
tracker.Reset();
|
||||
|
||||
tracker.TotalCount.ShouldBe(0L);
|
||||
tracker.GetCount(ClientKind.Client).ShouldBe(0L);
|
||||
tracker.GetCount(ClientKind.Router).ShouldBe(0L);
|
||||
}
|
||||
|
||||
// ── Account slow-consumer counters ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Account_IncrementSlowConsumers_tracks_count()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
|
||||
account.SlowConsumerCount.ShouldBe(0L);
|
||||
|
||||
account.IncrementSlowConsumers();
|
||||
account.IncrementSlowConsumers();
|
||||
account.IncrementSlowConsumers();
|
||||
|
||||
account.SlowConsumerCount.ShouldBe(3L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_ResetSlowConsumerCount_clears()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
|
||||
account.IncrementSlowConsumers();
|
||||
account.IncrementSlowConsumers();
|
||||
account.SlowConsumerCount.ShouldBe(2L);
|
||||
|
||||
account.ResetSlowConsumerCount();
|
||||
|
||||
account.SlowConsumerCount.ShouldBe(0L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Thread_safety_concurrent_increments()
|
||||
{
|
||||
var tracker = new SlowConsumerTracker();
|
||||
|
||||
Parallel.For(0, 1000, _ => tracker.RecordSlowConsumer(ClientKind.Client));
|
||||
|
||||
tracker.TotalCount.ShouldBe(1000L);
|
||||
tracker.GetCount(ClientKind.Client).ShouldBe(1000L);
|
||||
}
|
||||
}
|
||||
92
tests/NATS.Server.Core.Tests/StallGateTests.cs
Normal file
92
tests/NATS.Server.Core.Tests/StallGateTests.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
// Go reference: server/client.go (stc channel, stall gate backpressure)
|
||||
|
||||
public class StallGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Stall_gate_activates_at_threshold()
|
||||
{
|
||||
// Go reference: server/client.go stalledRoute — stalls at 75% capacity
|
||||
var gate = new NatsClient.StallGate(maxPending: 1000);
|
||||
|
||||
gate.IsStalled.ShouldBeFalse();
|
||||
|
||||
gate.UpdatePending(750); // 75% = threshold
|
||||
gate.IsStalled.ShouldBeTrue();
|
||||
|
||||
gate.UpdatePending(500); // below threshold — releases
|
||||
gate.IsStalled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stall_gate_blocks_producer()
|
||||
{
|
||||
// Go reference: server/client.go stc channel blocks sends
|
||||
var gate = new NatsClient.StallGate(maxPending: 100);
|
||||
gate.UpdatePending(80); // stalled — 80% > 75%
|
||||
|
||||
// Use a TCS to signal that the producer has entered WaitAsync
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var released = false;
|
||||
var task = Task.Run(async () =>
|
||||
{
|
||||
entered.SetResult();
|
||||
await gate.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
released = true;
|
||||
});
|
||||
|
||||
// Wait until the producer has reached WaitAsync before asserting
|
||||
await entered.Task;
|
||||
released.ShouldBeFalse(); // still blocked
|
||||
|
||||
gate.UpdatePending(50); // below threshold — releases
|
||||
|
||||
await task;
|
||||
released.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stall_gate_timeout_returns_false()
|
||||
{
|
||||
// Go reference: server/client.go stc timeout → close as slow consumer
|
||||
var gate = new NatsClient.StallGate(maxPending: 100);
|
||||
gate.UpdatePending(80); // stalled
|
||||
|
||||
var result = await gate.WaitAsync(TimeSpan.FromMilliseconds(50));
|
||||
result.ShouldBeFalse(); // timed out, not released
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stall_gate_not_stalled_below_threshold()
|
||||
{
|
||||
// Go reference: server/client.go — no stall when below threshold
|
||||
var gate = new NatsClient.StallGate(maxPending: 1000);
|
||||
|
||||
gate.UpdatePending(100); // well below 75%
|
||||
gate.IsStalled.ShouldBeFalse();
|
||||
|
||||
gate.UpdatePending(749); // just below 75%
|
||||
gate.IsStalled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stall_gate_wait_when_not_stalled_returns_immediately()
|
||||
{
|
||||
// Go reference: server/client.go — no stall, immediate return
|
||||
var gate = new NatsClient.StallGate(maxPending: 1000);
|
||||
|
||||
var result = await gate.WaitAsync(TimeSpan.FromSeconds(1));
|
||||
result.ShouldBeTrue(); // immediately released — not stalled
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stall_gate_release_is_idempotent()
|
||||
{
|
||||
// Release when not stalled should not throw
|
||||
var gate = new NatsClient.StallGate(maxPending: 100);
|
||||
|
||||
Should.NotThrow(() => gate.Release());
|
||||
Should.NotThrow(() => gate.Release());
|
||||
}
|
||||
}
|
||||
670
tests/NATS.Server.Core.Tests/Stress/ClusterStressTests.cs
Normal file
670
tests/NATS.Server.Core.Tests/Stress/ClusterStressTests.cs
Normal file
@@ -0,0 +1,670 @@
|
||||
// Go parity: golang/nats-server/server/norace_2_test.go
|
||||
// Covers: concurrent stream creation, parallel publish to clustered streams,
|
||||
// concurrent consumer creation and fetch, leader stepdown under load,
|
||||
// create-delete-recreate cycles, mixed concurrent operations, and large
|
||||
// batch fetch under concurrent publish — all using ClusterFixture.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Api;
|
||||
using NATS.Server.JetStream.Cluster;
|
||||
using NATS.Server.JetStream.Consumers;
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream.Publish;
|
||||
using ClusterFixture = NATS.Server.TestUtilities.JetStreamClusterFixture;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Stress;
|
||||
|
||||
/// <summary>
|
||||
/// Stress tests for clustered JetStream operations under concurrency.
|
||||
/// Uses JetStreamClusterFixture (in-process meta-group) to simulate cluster behaviour
|
||||
/// consistent with how Tasks 6-10 are tested.
|
||||
///
|
||||
/// Go ref: norace_2_test.go — cluster stress tests.
|
||||
/// </summary>
|
||||
public class ClusterStressTests
|
||||
{
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamCluster100ConcurrentStreamCreates norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_100_concurrent_stream_creates_all_succeed()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
const int count = 100;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
var created = new ConcurrentBag<string>();
|
||||
|
||||
await Parallel.ForEachAsync(Enumerable.Range(0, count), async (i, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await fx.CreateStreamAsync(
|
||||
$"CONCS{i}",
|
||||
[$"concs{i}.>"],
|
||||
1);
|
||||
|
||||
if (resp.Error is null)
|
||||
created.Add($"CONCS{i}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
created.Count.ShouldBe(count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamCluster50ConcurrentPublishes norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_50_concurrent_publishes_to_same_stream_all_stored()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
await fx.CreateStreamAsync("CONCPUB", ["concpub.>"], 1);
|
||||
|
||||
const int publishes = 50;
|
||||
var sequences = new ConcurrentBag<ulong>();
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
// Publish must be sequential because the in-process store serialises writes.
|
||||
// The concurrency in Go's norace tests comes from multiple goroutines being
|
||||
// scheduled — here we verify the sequential publish path is correct.
|
||||
for (var i = 0; i < publishes; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ack = await fx.PublishAsync($"concpub.event.{i}", $"payload-{i}");
|
||||
sequences.Add(ack.Seq);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
sequences.Count.ShouldBe(publishes);
|
||||
|
||||
var state = await fx.GetStreamStateAsync("CONCPUB");
|
||||
state.Messages.ShouldBe((ulong)publishes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamCluster20StreamsConcurrentPublish norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_20_streams_with_concurrent_publish_each_stores_correct_count()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
const int streamCount = 20;
|
||||
const int msgsPerStream = 10;
|
||||
|
||||
for (var i = 0; i < streamCount; i++)
|
||||
await fx.CreateStreamAsync($"MULTI{i}", [$"multi{i}.>"], 1);
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
// Independent streams publish in parallel — each has its own store.
|
||||
await Parallel.ForEachAsync(Enumerable.Range(0, streamCount), async (i, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var j = 0; j < msgsPerStream; j++)
|
||||
await fx.PublishAsync($"multi{i}.event", $"msg-{i}-{j}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
|
||||
for (var i = 0; i < streamCount; i++)
|
||||
{
|
||||
var state = await fx.GetStreamStateAsync($"MULTI{i}");
|
||||
state.Messages.ShouldBe((ulong)msgsPerStream,
|
||||
$"stream MULTI{i} should have {msgsPerStream} messages");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamClusterLeaderStepdownConcurrentPublish norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_leader_stepdown_during_concurrent_publishes_does_not_lose_data()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
await fx.CreateStreamAsync("STEPUB", ["stepub.>"], 3);
|
||||
|
||||
const int publishCount = 20;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
for (var i = 0; i < publishCount; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (i == 5)
|
||||
await fx.StepDownStreamLeaderAsync("STEPUB");
|
||||
|
||||
await fx.PublishAsync($"stepub.event.{i}", $"msg-{i}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
|
||||
var state = await fx.GetStreamStateAsync("STEPUB");
|
||||
state.Messages.ShouldBe((ulong)publishCount);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamCluster100ConcurrentConsumerCreates norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_100_concurrent_consumer_creates_all_succeed()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
await fx.CreateStreamAsync("CONCON", ["concon.>"], 1);
|
||||
|
||||
const int count = 100;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
await Parallel.ForEachAsync(Enumerable.Range(0, count), async (i, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await fx.CreateConsumerAsync("CONCON", $"consumer{i}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamCluster50ConcurrentFetches norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_50_sequential_fetches_on_same_consumer_all_succeed()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
await fx.CreateStreamAsync("CONFETCH", ["confetch.>"], 1);
|
||||
await fx.CreateConsumerAsync("CONFETCH", "fetcher");
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
await fx.PublishAsync("confetch.event", $"msg-{i}");
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var batch = await fx.FetchAsync("CONFETCH", "fetcher", 1);
|
||||
batch.ShouldNotBeNull();
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamClusterPublishFetchInterleave norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_concurrent_publish_and_fetch_interleaving_delivers_all_messages()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
await fx.CreateStreamAsync("INTERLEAVE", ["inter.>"], 1);
|
||||
await fx.CreateConsumerAsync("INTERLEAVE", "reader");
|
||||
|
||||
const int rounds = 10;
|
||||
const int msgsPerRound = 5;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
var totalFetched = 0;
|
||||
|
||||
for (var r = 0; r < rounds; r++)
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var m = 0; m < msgsPerRound; m++)
|
||||
await fx.PublishAsync("inter.event", $"round-{r}-msg-{m}");
|
||||
|
||||
var batch = await fx.FetchAsync("INTERLEAVE", "reader", msgsPerRound);
|
||||
Interlocked.Add(ref totalFetched, batch.Messages.Count);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
totalFetched.ShouldBe(rounds * msgsPerRound);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamClusterMetaStepdownDuringStreamCreate norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void Cluster_meta_stepdown_during_stream_creation_does_not_corrupt_state()
|
||||
{
|
||||
var meta = new JetStreamMetaGroup(5);
|
||||
var consumerManager = new ConsumerManager(meta);
|
||||
var streamManager = new StreamManager(meta, consumerManager: consumerManager);
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.Invoke(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
streamManager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = $"METACD{i}",
|
||||
Subjects = [$"mcd{i}.>"],
|
||||
Replicas = 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
},
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
meta.StepDown();
|
||||
Thread.Sleep(2);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamCluster10ConcurrentStreamDeletes norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_10_concurrent_stream_deletes_complete_without_error()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
const int count = 10;
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
await fx.CreateStreamAsync($"DEL{i}", [$"del{i}.>"], 1);
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
await Parallel.ForEachAsync(Enumerable.Range(0, count), async (i, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}DEL{i}", "{}");
|
||||
resp.ShouldNotBeNull();
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamClusterConcurrentAckAll norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_concurrent_ackall_operations_advance_consumer_correctly()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
await fx.CreateStreamAsync("ACKALL", ["ackall.>"], 1);
|
||||
await fx.CreateConsumerAsync("ACKALL", "acker", ackPolicy: AckPolicy.All);
|
||||
|
||||
const int msgCount = 50;
|
||||
for (var i = 0; i < msgCount; i++)
|
||||
await fx.PublishAsync("ackall.event", $"msg-{i}");
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
for (ulong seq = 1; seq <= msgCount; seq += 5)
|
||||
{
|
||||
try
|
||||
{
|
||||
fx.AckAll("ACKALL", "acker", seq);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamClusterMultiConsumerConcurrentFetch norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_multiple_consumers_each_see_all_messages_independently()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
await fx.CreateStreamAsync("MULTICONSUMER", ["mc.>"], 1);
|
||||
|
||||
const int consumers = 5;
|
||||
const int msgCount = 10;
|
||||
|
||||
for (var c = 0; c < consumers; c++)
|
||||
await fx.CreateConsumerAsync("MULTICONSUMER", $"reader{c}");
|
||||
|
||||
for (var i = 0; i < msgCount; i++)
|
||||
await fx.PublishAsync("mc.event", $"msg-{i}");
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
await Parallel.ForEachAsync(Enumerable.Range(0, consumers), async (c, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var batch = await fx.FetchAsync("MULTICONSUMER", $"reader{c}", msgCount);
|
||||
batch.Messages.Count.ShouldBe(msgCount,
|
||||
$"consumer reader{c} should see all {msgCount} messages");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamClusterRapidCreateDeleteRecreate norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_rapid_create_delete_recreate_cycle_50_iterations_correct()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
const int iterations = 50;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var createResp = await fx.CreateStreamAsync("RECYCLE", ["recycle.>"], 1);
|
||||
if (createResp.Error is null)
|
||||
{
|
||||
await fx.PublishAsync("recycle.event", $"msg-{i}");
|
||||
await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}RECYCLE", "{}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamClusterMixedConcurrentOperations norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_mixed_create_publish_fetch_delete_concurrently_does_not_corrupt()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
|
||||
await fx.CreateStreamAsync("MIXEDBASE", ["mixed.>"], 1);
|
||||
await fx.CreateConsumerAsync("MIXEDBASE", "mixedreader");
|
||||
|
||||
const int opsPerTask = 20;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
await Task.WhenAll(
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < opsPerTask; i++)
|
||||
await fx.CreateStreamAsync($"MXNEW{i}", [$"mxnew{i}.>"], 1);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}),
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < opsPerTask; i++)
|
||||
await fx.PublishAsync("mixed.event", $"msg-{i}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}),
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < opsPerTask; i++)
|
||||
_ = await fx.FetchAsync("MIXEDBASE", "mixedreader", 1);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}),
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < opsPerTask; i++)
|
||||
_ = await fx.GetStreamInfoAsync("MIXEDBASE");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}));
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamClusterConcurrentStreamInfo norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_concurrent_stream_info_queries_during_publishes_are_safe()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
await fx.CreateStreamAsync("INFOLOAD", ["infoload.>"], 1);
|
||||
|
||||
const int ops = 50;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
await Task.WhenAll(
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < ops; i++)
|
||||
await fx.PublishAsync("infoload.event", $"msg-{i}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}),
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < ops * 2; i++)
|
||||
_ = await fx.GetStreamInfoAsync("INFOLOAD");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}),
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < ops * 2; i++)
|
||||
_ = await fx.GetStreamStateAsync("INFOLOAD");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}));
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamClusterLargeBatchFetch norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_large_batch_fetch_500_messages_under_concurrent_publish()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
await fx.CreateStreamAsync("LARGEBATCH", ["lb.>"], 1);
|
||||
await fx.CreateConsumerAsync("LARGEBATCH", "batchreader");
|
||||
|
||||
const int totalMsgs = 500;
|
||||
|
||||
for (var i = 0; i < totalMsgs; i++)
|
||||
await fx.PublishAsync("lb.event", $"payload-{i}");
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
var fetchedCount = 0;
|
||||
|
||||
await Task.WhenAll(
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var batch = await fx.FetchAsync("LARGEBATCH", "batchreader", totalMsgs);
|
||||
Interlocked.Add(ref fetchedCount, batch.Messages.Count);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}),
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 50; i++)
|
||||
await fx.PublishAsync("lb.event", $"extra-{i}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}));
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
fetchedCount.ShouldBe(totalMsgs);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamClusterConsumerDeleteConcurrent norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_concurrent_consumer_delete_and_create_is_thread_safe()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
await fx.CreateStreamAsync("CONDEL", ["condel.>"], 1);
|
||||
|
||||
const int initialCount = 20;
|
||||
for (var i = 0; i < initialCount; i++)
|
||||
await fx.CreateConsumerAsync("CONDEL", $"c{i}");
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
await Task.WhenAll(
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < initialCount / 2; i++)
|
||||
await fx.RequestAsync(
|
||||
$"{JetStreamApiSubjects.ConsumerDelete}CONDEL.c{i}", "{}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}),
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = initialCount; i < initialCount + 10; i++)
|
||||
await fx.CreateConsumerAsync("CONDEL", $"c{i}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}),
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 30; i++)
|
||||
_ = await fx.GetStreamInfoAsync("CONDEL");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}));
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceJetStreamClusterStreamPurgeConcurrentFetch norace_2_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Cluster_stream_purge_concurrent_with_fetch_does_not_deadlock()
|
||||
{
|
||||
await using var fx = await ClusterFixture.StartAsync(nodes: 3);
|
||||
await fx.CreateStreamAsync("PURGELOAD", ["pl.>"], 1);
|
||||
await fx.CreateConsumerAsync("PURGELOAD", "purgereader");
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
await fx.PublishAsync("pl.event", $"msg-{i}");
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
await Task.WhenAll(
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}PURGELOAD", "{}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}),
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = await fx.FetchAsync("PURGELOAD", "purgereader", 50);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}));
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,915 @@
|
||||
// Go parity: golang/nats-server/server/norace_1_test.go
|
||||
// Covers: concurrent publish/subscribe thread safety, SubList trie integrity
|
||||
// under high concurrency, wildcard routing under load, queue group balancing,
|
||||
// cache invalidation safety, and subject tree concurrent insert/remove.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Stress;
|
||||
|
||||
/// <summary>
|
||||
/// Stress tests for concurrent pub/sub operations on the in-process SubList and SubjectMatch
|
||||
/// classes. All tests use Parallel.For / Task.WhenAll to exercise thread safety directly
|
||||
/// without spinning up a real NatsServer.
|
||||
///
|
||||
/// Go ref: norace_1_test.go — concurrent subscription and matching operations.
|
||||
/// </summary>
|
||||
public class ConcurrentPubSubStressTests
|
||||
{
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceSublistConcurrent100Subscribers norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_100_concurrent_subscribers_all_inserted_without_error()
|
||||
{
|
||||
// 100 concurrent goroutines each Subscribe to the same subject and then Match.
|
||||
using var subList = new SubList();
|
||||
const int count = 100;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.For(0, count, i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
subList.Insert(new Subscription { Subject = "stress.concurrent", Sid = $"s{i}" });
|
||||
var result = subList.Match("stress.concurrent");
|
||||
result.PlainSubs.Length.ShouldBeGreaterThan(0);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
subList.Count.ShouldBe((uint)count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRace50ConcurrentPublishers norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_50_concurrent_publishers_produce_correct_match_counts()
|
||||
{
|
||||
// 50 goroutines each publish 100 times to their own subject.
|
||||
// Verifies that Match never throws even under heavy concurrent write/read.
|
||||
using var subList = new SubList();
|
||||
const int publishers = 50;
|
||||
const int messagesEach = 100;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
// Pre-insert one subscription per publisher subject
|
||||
for (var i = 0; i < publishers; i++)
|
||||
{
|
||||
subList.Insert(new Subscription
|
||||
{
|
||||
Subject = $"pub.stress.{i}",
|
||||
Sid = $"pre-{i}",
|
||||
});
|
||||
}
|
||||
|
||||
Parallel.For(0, publishers, i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var j = 0; j < messagesEach; j++)
|
||||
{
|
||||
var result = subList.Match($"pub.stress.{i}");
|
||||
result.PlainSubs.Length.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceSubUnsubConcurrent norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_concurrent_subscribe_and_unsubscribe_does_not_crash()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
const int ops = 300;
|
||||
var subs = new ConcurrentBag<Subscription>();
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
// Concurrent inserts and removes — neither side holds a reference the other
|
||||
// side needs, so any interleaving is valid as long as it doesn't throw.
|
||||
Parallel.Invoke(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < ops; i++)
|
||||
{
|
||||
var sub = new Subscription { Subject = $"unsub.{i % 30}", Sid = $"ins-{i}" };
|
||||
subList.Insert(sub);
|
||||
subs.Add(sub);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
},
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var sub in subs.Take(ops / 2))
|
||||
subList.Remove(sub);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceConcurrentMatchOperations norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_concurrent_match_operations_are_thread_safe()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
subList.Insert(new Subscription
|
||||
{
|
||||
Subject = $"match.safe.{i % 10}",
|
||||
Sid = $"m{i}",
|
||||
});
|
||||
}
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
// 200 threads all calling Match simultaneously
|
||||
Parallel.For(0, 200, i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = subList.Match($"match.safe.{i % 10}");
|
||||
result.ShouldNotBeNull();
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRace1000ConcurrentSubscriptions norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_handles_1000_concurrent_subscriptions_without_error()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
const int count = 1000;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.For(0, count, i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
subList.Insert(new Subscription
|
||||
{
|
||||
Subject = $"big.load.{i % 100}",
|
||||
Sid = $"big-{i}",
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
subList.Count.ShouldBe((uint)count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRace10000SubscriptionsWithConcurrentMatch norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_handles_10000_subscriptions_with_concurrent_matches()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
const int count = 10_000;
|
||||
|
||||
// Sequential insert to avoid any write-write contention noise
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
subList.Insert(new Subscription
|
||||
{
|
||||
Subject = $"huge.{i % 200}.data",
|
||||
Sid = $"h{i}",
|
||||
});
|
||||
}
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.For(0, 500, i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = subList.Match($"huge.{i % 200}.data");
|
||||
// Each subject bucket has count/200 = 50 subscribers
|
||||
result.PlainSubs.Length.ShouldBe(50);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceWildcardConcurrentPub norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_wildcard_subjects_routed_correctly_under_concurrent_match()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
|
||||
subList.Insert(new Subscription { Subject = "wc.*", Sid = "pwc" });
|
||||
subList.Insert(new Subscription { Subject = "wc.>", Sid = "fwc" });
|
||||
subList.Insert(new Subscription { Subject = "wc.specific", Sid = "lit" });
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.For(0, 400, i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var subject = (i % 3) switch
|
||||
{
|
||||
0 => "wc.specific",
|
||||
1 => "wc.anything",
|
||||
_ => "wc.deep.nested",
|
||||
};
|
||||
var result = subList.Match(subject);
|
||||
// wc.* matches single-token, wc.> matches all
|
||||
result.PlainSubs.Length.ShouldBeGreaterThan(0);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceQueueGroupBalancingUnderLoad norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_queue_group_balancing_correct_under_concurrent_load()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
const int memberCount = 20;
|
||||
|
||||
for (var i = 0; i < memberCount; i++)
|
||||
{
|
||||
subList.Insert(new Subscription
|
||||
{
|
||||
Subject = "queue.load",
|
||||
Queue = "workers",
|
||||
Sid = $"q{i}",
|
||||
});
|
||||
}
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.For(0, 200, i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = subList.Match("queue.load");
|
||||
result.QueueSubs.Length.ShouldBe(1);
|
||||
result.QueueSubs[0].Length.ShouldBe(memberCount);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRace100ConcurrentPubsSameSubject norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_100_concurrent_publishes_to_same_subject_all_processed()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
subList.Insert(new Subscription { Subject = "same.subject", Sid = "single" });
|
||||
|
||||
var matchCount = 0;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.For(0, 100, _ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = subList.Match("same.subject");
|
||||
result.PlainSubs.Length.ShouldBe(1);
|
||||
Interlocked.Increment(ref matchCount);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
matchCount.ShouldBe(100);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceConcurrentIdenticalSubjects norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_concurrent_subscribe_with_identical_subjects_all_inserted()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
const int count = 100;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.For(0, count, i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
subList.Insert(new Subscription
|
||||
{
|
||||
Subject = "identical.subject",
|
||||
Sid = $"ident-{i}",
|
||||
});
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
var result = subList.Match("identical.subject");
|
||||
result.PlainSubs.Length.ShouldBe(count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceSubscribePublishInterleaving norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_subscribe_publish_interleaving_does_not_lose_messages()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
var totalMatches = 0;
|
||||
|
||||
Parallel.Invoke(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
subList.Insert(new Subscription
|
||||
{
|
||||
Subject = $"interleave.{i % 10}",
|
||||
Sid = $"il-{i}",
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
},
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
var result = subList.Match($"interleave.{i % 10}");
|
||||
Interlocked.Add(ref totalMatches, result.PlainSubs.Length);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
// We cannot assert a fixed count because of race between sub insert and match,
|
||||
// but no exception is the primary invariant.
|
||||
totalMatches.ShouldBeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceCacheInvalidationConcurrent norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_cache_invalidation_is_thread_safe_under_concurrent_modifications()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
|
||||
// Fill the cache
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var sub = new Subscription { Subject = $"cache.inv.{i}", Sid = $"ci-{i}" };
|
||||
subList.Insert(sub);
|
||||
_ = subList.Match($"cache.inv.{i}");
|
||||
}
|
||||
|
||||
subList.CacheCount.ShouldBeGreaterThan(0);
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
// Concurrent reads (cache hits) and writes (cache invalidation)
|
||||
Parallel.Invoke(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 200; i++)
|
||||
_ = subList.Match($"cache.inv.{i % 100}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
},
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 100; i < 150; i++)
|
||||
{
|
||||
subList.Insert(new Subscription
|
||||
{
|
||||
Subject = $"cache.inv.{i}",
|
||||
Sid = $"cinew-{i}",
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRacePurgeAndMatchConcurrent norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_concurrent_batch_remove_and_match_do_not_deadlock()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
var inserted = new List<Subscription>();
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
var sub = new Subscription { Subject = $"purge.match.{i % 20}", Sid = $"pm-{i}" };
|
||||
subList.Insert(sub);
|
||||
inserted.Add(sub);
|
||||
}
|
||||
|
||||
Parallel.Invoke(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
subList.RemoveBatch(inserted.Take(100));
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
},
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 100; i++)
|
||||
_ = subList.Match($"purge.match.{i % 20}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRace1000Subjects10SubscribersEach norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_1000_subjects_10_subscribers_each_concurrent_match_correct()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
const int subjects = 200; // reduced for CI speed; same shape as 1000
|
||||
const int subsPerSubject = 5;
|
||||
|
||||
for (var s = 0; s < subjects; s++)
|
||||
{
|
||||
for (var n = 0; n < subsPerSubject; n++)
|
||||
{
|
||||
subList.Insert(new Subscription
|
||||
{
|
||||
Subject = $"big.tree.{s}",
|
||||
Sid = $"bt-{s}-{n}",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.For(0, subjects * 3, i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = subList.Match($"big.tree.{i % subjects}");
|
||||
result.PlainSubs.Length.ShouldBe(subsPerSubject);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceMixedWildcardLiteralConcurrent norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_mixed_wildcard_and_literal_subscriptions_under_concurrent_match()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
|
||||
// Mix of literals, * wildcards, and > wildcards
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
subList.Insert(new Subscription { Subject = $"mix.{i}.literal", Sid = $"lit-{i}" });
|
||||
subList.Insert(new Subscription { Subject = $"mix.{i}.*", Sid = $"pwc-{i}" });
|
||||
}
|
||||
|
||||
subList.Insert(new Subscription { Subject = "mix.>", Sid = "fwc-root" });
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.For(0, 300, i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var idx = i % 20;
|
||||
var result = subList.Match($"mix.{idx}.literal");
|
||||
// Matches: the literal sub, the * wildcard sub, and the > sub
|
||||
result.PlainSubs.Length.ShouldBe(3);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceHighThroughputPublish norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_high_throughput_10000_messages_to_single_subscriber()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
subList.Insert(new Subscription { Subject = "throughput.test", Sid = "tp1" });
|
||||
|
||||
var count = 0;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
for (var i = 0; i < 10_000; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = subList.Match("throughput.test");
|
||||
result.PlainSubs.Length.ShouldBe(1);
|
||||
count++;
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
}
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
count.ShouldBe(10_000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceQueueSubConcurrentUnsubscribe norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_concurrent_queue_group_subscribe_and_unsubscribe_is_safe()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
const int ops = 200;
|
||||
var inserted = new ConcurrentBag<Subscription>();
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.Invoke(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < ops; i++)
|
||||
{
|
||||
var sub = new Subscription
|
||||
{
|
||||
Subject = $"qg.stress.{i % 10}",
|
||||
Queue = $"grp-{i % 5}",
|
||||
Sid = $"qgs-{i}",
|
||||
};
|
||||
subList.Insert(sub);
|
||||
inserted.Add(sub);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
},
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var sub in inserted.Take(ops / 2))
|
||||
subList.Remove(sub);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
},
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < ops; i++)
|
||||
_ = subList.Match($"qg.stress.{i % 10}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRace500Subjects5SubscribersEach norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_500_subjects_5_subscribers_each_concurrent_match_returns_correct_results()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
const int subjects = 100; // scaled for CI speed
|
||||
const int subsPerSubject = 5;
|
||||
|
||||
for (var s = 0; s < subjects; s++)
|
||||
{
|
||||
for (var n = 0; n < subsPerSubject; n++)
|
||||
{
|
||||
subList.Insert(new Subscription
|
||||
{
|
||||
Subject = $"five.subs.{s}",
|
||||
Sid = $"fs-{s}-{n}",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
var correctCount = 0;
|
||||
|
||||
Parallel.For(0, subjects * 4, i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = subList.Match($"five.subs.{i % subjects}");
|
||||
if (result.PlainSubs.Length == subsPerSubject)
|
||||
Interlocked.Increment(ref correctCount);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
correctCount.ShouldBe(subjects * 4);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceSubjectValidationConcurrent norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubjectMatch_validation_is_thread_safe_under_concurrent_calls()
|
||||
{
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
var validCount = 0;
|
||||
|
||||
Parallel.For(0, 1000, i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var subject = (i % 4) switch
|
||||
{
|
||||
0 => $"valid.subject.{i}",
|
||||
1 => $"valid.*.wildcard",
|
||||
2 => $"valid.>",
|
||||
_ => string.Empty, // invalid
|
||||
};
|
||||
var isValid = SubjectMatch.IsValidSubject(subject);
|
||||
if (isValid)
|
||||
Interlocked.Increment(ref validCount);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
// 750 valid, 250 empty (invalid)
|
||||
validCount.ShouldBe(750);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceHasInterestConcurrent norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_has_interest_returns_consistent_results_under_concurrent_insert()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
var interestFoundCount = 0;
|
||||
|
||||
Parallel.Invoke(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
subList.Insert(new Subscription
|
||||
{
|
||||
Subject = $"interest.{i % 20}",
|
||||
Sid = $"hi-{i}",
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
},
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
if (subList.HasInterest($"interest.{i % 20}"))
|
||||
Interlocked.Increment(ref interestFoundCount);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
interestFoundCount.ShouldBeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceNumInterestConcurrent norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_num_interest_is_consistent_under_high_concurrency()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
const int subCount = 80;
|
||||
|
||||
for (var i = 0; i < subCount; i++)
|
||||
{
|
||||
subList.Insert(new Subscription
|
||||
{
|
||||
Subject = "num.interest.stress",
|
||||
Sid = $"nis-{i}",
|
||||
});
|
||||
}
|
||||
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.For(0, 400, _ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var (plain, queue) = subList.NumInterest("num.interest.stress");
|
||||
plain.ShouldBe(subCount);
|
||||
queue.ShouldBe(0);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceReverseMatchConcurrent norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_reverse_match_concurrent_with_inserts_does_not_throw()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.Invoke(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
subList.Insert(new Subscription
|
||||
{
|
||||
Subject = $"rev.stress.{i % 10}",
|
||||
Sid = $"rs-{i}",
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
},
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 150; i++)
|
||||
_ = subList.ReverseMatch($"rev.stress.{i % 10}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceStatsConsistencyUnderLoad norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public void SubList_stats_remain_consistent_under_concurrent_insert_remove_match()
|
||||
{
|
||||
using var subList = new SubList();
|
||||
const int ops = 300;
|
||||
var insertedSubs = new ConcurrentBag<Subscription>();
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
Parallel.Invoke(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < ops; i++)
|
||||
{
|
||||
var sub = new Subscription
|
||||
{
|
||||
Subject = $"stats.stress.{i % 30}",
|
||||
Sid = $"ss-{i}",
|
||||
};
|
||||
subList.Insert(sub);
|
||||
insertedSubs.Add(sub);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
},
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < ops; i++)
|
||||
_ = subList.Match($"stats.stress.{i % 30}");
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
},
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 50; i++)
|
||||
_ = subList.Stats();
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
|
||||
var finalStats = subList.Stats();
|
||||
finalStats.NumInserts.ShouldBeGreaterThan(0UL);
|
||||
finalStats.NumMatches.ShouldBeGreaterThan(0UL);
|
||||
}
|
||||
}
|
||||
738
tests/NATS.Server.Core.Tests/Stress/SlowConsumerStressTests.cs
Normal file
738
tests/NATS.Server.Core.Tests/Stress/SlowConsumerStressTests.cs
Normal file
@@ -0,0 +1,738 @@
|
||||
// Go parity: golang/nats-server/server/norace_1_test.go
|
||||
// Covers: slow consumer detection, backpressure stats, rapid subscribe/unsubscribe
|
||||
// cycles, multi-client connection stress, large message delivery, and connection
|
||||
// lifecycle stability under load using real NatsServer instances.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests.Stress;
|
||||
|
||||
/// <summary>
|
||||
/// Stress tests for slow consumer behaviour and connection lifecycle using real NatsServer
|
||||
/// instances wired with raw Socket connections following the same pattern as
|
||||
/// ClientSlowConsumerTests.cs and ServerTests.cs.
|
||||
///
|
||||
/// Go ref: norace_1_test.go — slow consumer, connection churn, and load tests.
|
||||
/// </summary>
|
||||
public class SlowConsumerStressTests
|
||||
{
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private static async Task<Socket> ConnectRawAsync(int port)
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||||
// Drain the INFO line
|
||||
var buf = new byte[4096];
|
||||
await sock.ReceiveAsync(buf, SocketFlags.None);
|
||||
return sock;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceSlowConsumerStatIncrement norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Slow_consumer_stat_incremented_when_client_falls_behind()
|
||||
{
|
||||
// Go: TestNoClientLeakOnSlowConsumer — verify Stats.SlowConsumers increments.
|
||||
const long maxPending = 512;
|
||||
const int payloadSize = 256;
|
||||
const int floodCount = 30;
|
||||
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port, MaxPending = maxPending },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var slowSub = await ConnectRawAsync(port);
|
||||
await slowSub.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB sc.stat 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(slowSub, "PONG");
|
||||
|
||||
using var pub = await ConnectRawAsync(port);
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\n"));
|
||||
|
||||
var payload = new string('Z', payloadSize);
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < floodCount; i++)
|
||||
sb.Append($"PUB sc.stat {payloadSize}\r\n{payload}\r\n");
|
||||
sb.Append("PING\r\n");
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG", 5000);
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
var stats = server.Stats;
|
||||
Interlocked.Read(ref stats.SlowConsumers).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceSlowConsumerClientsTrackedIndependently norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Multiple_slow_consumers_tracked_independently_in_stats()
|
||||
{
|
||||
const long maxPending = 256;
|
||||
const int payloadSize = 128;
|
||||
const int floodCount = 20;
|
||||
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port, MaxPending = maxPending },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Two independent slow subscribers
|
||||
using var slow1 = await ConnectRawAsync(port);
|
||||
using var slow2 = await ConnectRawAsync(port);
|
||||
|
||||
await slow1.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB multi.slow 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(slow1, "PONG");
|
||||
|
||||
await slow2.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB multi.slow 2\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(slow2, "PONG");
|
||||
|
||||
using var pub = await ConnectRawAsync(port);
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\n"));
|
||||
|
||||
var payload = new string('A', payloadSize);
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < floodCount; i++)
|
||||
sb.Append($"PUB multi.slow {payloadSize}\r\n{payload}\r\n");
|
||||
sb.Append("PING\r\n");
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG", 5000);
|
||||
|
||||
await Task.Delay(600);
|
||||
|
||||
var stats = server.Stats;
|
||||
Interlocked.Read(ref stats.SlowConsumers).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRacePublisherBackpressure norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Fast_publisher_with_slow_reader_generates_backpressure_stats()
|
||||
{
|
||||
const long maxPending = 512;
|
||||
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port, MaxPending = maxPending },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var sub = await ConnectRawAsync(port);
|
||||
await sub.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB bp.test 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
|
||||
|
||||
using var pub = await ConnectRawAsync(port);
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\n"));
|
||||
|
||||
var payload = new string('P', 400);
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < 25; i++)
|
||||
sb.Append($"PUB bp.test 400\r\n{payload}\r\n");
|
||||
sb.Append("PING\r\n");
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG", 5000);
|
||||
await Task.Delay(400);
|
||||
|
||||
var stats = server.Stats;
|
||||
// At least the SlowConsumers counter or client count dropped
|
||||
(Interlocked.Read(ref stats.SlowConsumers) > 0 || server.ClientCount <= 2)
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRace100RapidPublishes norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Subscriber_receives_messages_after_100_rapid_publishes()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var sub = await ConnectRawAsync(port);
|
||||
await sub.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB rapid 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
|
||||
|
||||
using var pub = await ConnectRawAsync(port);
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\n"));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < 100; i++)
|
||||
sb.Append("PUB rapid 4\r\nping\r\n");
|
||||
sb.Append("PING\r\n");
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG", 5000);
|
||||
|
||||
var received = await SocketTestHelper.ReadUntilAsync(sub, "MSG rapid", 5000);
|
||||
received.ShouldContain("MSG rapid");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceConcurrentSubscribeStartup norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Concurrent_publish_and_subscribe_startup_does_not_crash_server()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var tasks = Enumerable.Range(0, 10).Select(async i =>
|
||||
{
|
||||
using var sock = await ConnectRawAsync(port);
|
||||
await sock.SendAsync(
|
||||
Encoding.ASCII.GetBytes($"CONNECT {{\"verbose\":false}}\r\nSUB conc.start.{i} {i + 1}\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sock, "PONG", 3000);
|
||||
});
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
server.ClientCount.ShouldBeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceLargeMessageMultipleSubscribers norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Large_message_published_and_received_by_multiple_subscribers()
|
||||
{
|
||||
// Use 8KB payload — large enough to span multiple TCP segments but small
|
||||
// enough to stay well within the default MaxPending limit in CI.
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
const int payloadSize = 8192;
|
||||
var payload = new string('L', payloadSize);
|
||||
|
||||
try
|
||||
{
|
||||
using var sub1 = await ConnectRawAsync(port);
|
||||
using var sub2 = await ConnectRawAsync(port);
|
||||
|
||||
await sub1.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB large.msg 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub1, "PONG");
|
||||
|
||||
await sub2.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB large.msg 2\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub2, "PONG");
|
||||
|
||||
using var pub = await ConnectRawAsync(port);
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\n"));
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes($"PUB large.msg {payloadSize}\r\n{payload}\r\nPING\r\n"));
|
||||
// Use a longer timeout for large message delivery
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG", 10000);
|
||||
|
||||
var r1 = await SocketTestHelper.ReadUntilAsync(sub1, "MSG large.msg", 10000);
|
||||
var r2 = await SocketTestHelper.ReadUntilAsync(sub2, "MSG large.msg", 10000);
|
||||
|
||||
r1.ShouldContain("MSG large.msg");
|
||||
r2.ShouldContain("MSG large.msg");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceSubscribeUnsubscribeResubscribeCycle norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Subscribe_unsubscribe_resubscribe_cycle_100_times_without_error()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = await ConnectRawAsync(port);
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\n"));
|
||||
|
||||
for (var i = 1; i <= 100; i++)
|
||||
{
|
||||
await client.SendAsync(
|
||||
Encoding.ASCII.GetBytes($"SUB resub.cycle {i}\r\nUNSUB {i}\r\n"));
|
||||
}
|
||||
|
||||
await client.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
var resp = await SocketTestHelper.ReadUntilAsync(client, "PONG", 5000);
|
||||
resp.ShouldContain("PONG");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceSubscriberReceivesAfterPause norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Subscriber_receives_messages_correctly_after_brief_pause()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var sub = await ConnectRawAsync(port);
|
||||
await sub.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB pause.sub 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
|
||||
|
||||
// Brief pause simulating a subscriber that drifts slightly
|
||||
await Task.Delay(100);
|
||||
|
||||
using var pub = await ConnectRawAsync(port);
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\n"));
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PUB pause.sub 5\r\nhello\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG", 5000);
|
||||
|
||||
var received = await SocketTestHelper.ReadUntilAsync(sub, "hello", 5000);
|
||||
received.ShouldContain("hello");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceMultipleClientConnectDisconnect norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Multiple_client_connections_and_disconnections_leave_server_stable()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Connect and disconnect 20 clients sequentially to avoid hammering the port
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
using var sock = await ConnectRawAsync(port);
|
||||
await sock.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sock, "PONG", 3000);
|
||||
sock.Close();
|
||||
}
|
||||
|
||||
// Brief settle time
|
||||
await Task.Delay(200);
|
||||
|
||||
// Server should still accept new connections
|
||||
using var final = await ConnectRawAsync(port);
|
||||
await final.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nPING\r\n"));
|
||||
var resp = await SocketTestHelper.ReadUntilAsync(final, "PONG", 3000);
|
||||
resp.ShouldContain("PONG");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceStatsCountersUnderLoad norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Stats_in_and_out_bytes_increment_correctly_under_load()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var sub = await ConnectRawAsync(port);
|
||||
await sub.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB stats.load 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
|
||||
|
||||
using var pub = await ConnectRawAsync(port);
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\n"));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < 50; i++)
|
||||
sb.Append("PUB stats.load 10\r\n0123456789\r\n");
|
||||
sb.Append("PING\r\n");
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG", 5000);
|
||||
|
||||
await Task.Delay(200);
|
||||
|
||||
var stats = server.Stats;
|
||||
Interlocked.Read(ref stats.InMsgs).ShouldBeGreaterThan(0);
|
||||
Interlocked.Read(ref stats.OutMsgs).ShouldBeGreaterThan(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceRapidConnectDisconnect norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Rapid_connect_disconnect_cycles_do_not_corrupt_server_state()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// 30 rapid sequential connect + disconnect cycles
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||||
// Drain INFO
|
||||
var buf = new byte[512];
|
||||
await sock.ReceiveAsync(buf, SocketFlags.None);
|
||||
// Immediately close — simulates a client that disconnects without CONNECT
|
||||
sock.Close();
|
||||
sock.Dispose();
|
||||
}
|
||||
|
||||
await Task.Delay(300);
|
||||
|
||||
// Server should still respond
|
||||
using var healthy = await ConnectRawAsync(port);
|
||||
await healthy.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nPING\r\n"));
|
||||
var resp = await SocketTestHelper.ReadUntilAsync(healthy, "PONG", 3000);
|
||||
resp.ShouldContain("PONG");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRacePublishWithCancellation norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Server_accepts_connection_after_cancelled_client_task()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var serverCts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(serverCts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var clientCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
// Attempt a receive with a very short timeout — the token will cancel the read
|
||||
// but the server should not be destabilised by the abrupt disconnect.
|
||||
try
|
||||
{
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||||
var buf = new byte[512];
|
||||
await sock.ReceiveAsync(buf, SocketFlags.None, clientCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
|
||||
await Task.Delay(200);
|
||||
|
||||
// Server should still function
|
||||
using var good = await ConnectRawAsync(port);
|
||||
await good.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nPING\r\n"));
|
||||
var resp = await SocketTestHelper.ReadUntilAsync(good, "PONG", 3000);
|
||||
resp.ShouldContain("PONG");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await serverCts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceSlowConsumerClientCountDrops norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Slow_consumer_is_removed_from_client_count_after_detection()
|
||||
{
|
||||
const long maxPending = 512;
|
||||
const int payloadSize = 256;
|
||||
const int floodCount = 20;
|
||||
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port, MaxPending = maxPending },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var slowSub = await ConnectRawAsync(port);
|
||||
await slowSub.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB drop.test 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(slowSub, "PONG");
|
||||
|
||||
using var pub = await ConnectRawAsync(port);
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\n"));
|
||||
|
||||
var payload = new string('D', payloadSize);
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < floodCount; i++)
|
||||
sb.Append($"PUB drop.test {payloadSize}\r\n{payload}\r\n");
|
||||
sb.Append("PING\r\n");
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG", 5000);
|
||||
|
||||
await Task.Delay(600);
|
||||
|
||||
// Publisher is still alive; slow subscriber has been dropped
|
||||
server.ClientCount.ShouldBeLessThanOrEqualTo(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceSubjectMatchingUnderConcurrentConnections norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Server_delivers_to_correct_subscriber_when_multiple_subjects_active()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var sub1 = await ConnectRawAsync(port);
|
||||
using var sub2 = await ConnectRawAsync(port);
|
||||
|
||||
await sub1.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB target.A 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub1, "PONG");
|
||||
|
||||
await sub2.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB target.B 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub2, "PONG");
|
||||
|
||||
using var pub = await ConnectRawAsync(port);
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\n"));
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PUB target.A 5\r\nhello\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG", 5000);
|
||||
|
||||
var r1 = await SocketTestHelper.ReadUntilAsync(sub1, "hello", 3000);
|
||||
r1.ShouldContain("MSG target.A");
|
||||
|
||||
// sub2 should NOT have received the target.A message
|
||||
sub2.ReceiveTimeout = 200;
|
||||
var buf = new byte[512];
|
||||
var n = 0;
|
||||
try { n = sub2.Receive(buf); } catch (SocketException) { }
|
||||
var s2Data = Encoding.ASCII.GetString(buf, 0, n);
|
||||
s2Data.ShouldNotContain("target.A");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Go: TestNoRaceServerRejectsPayloadOverLimit norace_1_test.go
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Stress")]
|
||||
public async Task Server_remains_stable_after_processing_many_medium_sized_messages()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(
|
||||
new NatsOptions { Port = port },
|
||||
NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var sub = await ConnectRawAsync(port);
|
||||
await sub.SendAsync(
|
||||
Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\nSUB medium.msgs 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG");
|
||||
|
||||
using var pub = await ConnectRawAsync(port);
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false}\r\n"));
|
||||
|
||||
var payload = new string('M', 1024); // 1 KB each
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < 200; i++)
|
||||
sb.Append($"PUB medium.msgs 1024\r\n{payload}\r\n");
|
||||
sb.Append("PING\r\n");
|
||||
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG", 10000);
|
||||
|
||||
var stats = server.Stats;
|
||||
Interlocked.Read(ref stats.InMsgs).ShouldBeGreaterThanOrEqualTo(200);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class SubListAsyncCacheSweepTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Cache_sweep_runs_async_and_prunes_stale_entries_without_write_locking_match_path()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
sl.Insert(new Subscription { Subject = ">", Sid = "all" });
|
||||
|
||||
for (var i = 0; i < 1500; i++)
|
||||
_ = sl.Match($"orders.{i}");
|
||||
|
||||
var initial = sl.CacheCount;
|
||||
initial.ShouldBeGreaterThan(1024);
|
||||
|
||||
await sl.TriggerCacheSweepAsyncForTest();
|
||||
sl.CacheCount.ShouldBeLessThan(initial);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class SubListHighFanoutOptimizationTests
|
||||
{
|
||||
[Fact]
|
||||
public void High_fanout_nodes_enable_packed_list_optimization()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
for (var i = 0; i < 300; i++)
|
||||
{
|
||||
sl.Insert(new Subscription
|
||||
{
|
||||
Subject = "orders.created",
|
||||
Sid = i.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
sl.HighFanoutNodeCountForTest.ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class SubListMatchBytesTests
|
||||
{
|
||||
[Fact]
|
||||
public void MatchBytes_matches_subject_without_string_allocation_and_respects_remote_filter()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
sl.MatchBytes("orders.created"u8).PlainSubs.Length.ShouldBe(0);
|
||||
|
||||
sl.Insert(new Subscription { Subject = "orders.*", Sid = "1" });
|
||||
sl.MatchBytes("orders.created"u8).PlainSubs.Length.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class SubListNotificationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Interest_change_notifications_are_emitted_for_local_and_remote_changes()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
var changes = new List<InterestChange>();
|
||||
sl.InterestChanged += changes.Add;
|
||||
|
||||
var sub = new Subscription { Subject = "orders.created", Sid = "1" };
|
||||
sl.Insert(sub);
|
||||
sl.Remove(sub);
|
||||
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r1", "A"));
|
||||
sl.ApplyRemoteSub(RemoteSubscription.Removal("orders.*", null, "r1", "A"));
|
||||
|
||||
changes.Count.ShouldBe(4);
|
||||
changes.Select(c => c.Kind).ShouldContain(InterestChangeKind.LocalAdded);
|
||||
changes.Select(c => c.Kind).ShouldContain(InterestChangeKind.RemoteAdded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class SubListQueueWeightTests
|
||||
{
|
||||
[Fact]
|
||||
public void Remote_queue_weight_expands_matches()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", "q", "r1", "A", QueueWeight: 3));
|
||||
|
||||
var matches = sl.MatchRemote("A", "orders.created");
|
||||
matches.Count.ShouldBe(3);
|
||||
matches.ShouldAllBe(m => m.Queue == "q");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class SubListRemoteFilterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Match_remote_filters_by_account_and_subject()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r1", "A"));
|
||||
sl.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "r2", "B"));
|
||||
|
||||
var aMatches = sl.MatchRemote("A", "orders.created");
|
||||
aMatches.Count.ShouldBe(1);
|
||||
aMatches[0].Account.ShouldBe("A");
|
||||
}
|
||||
}
|
||||
549
tests/NATS.Server.Core.Tests/SubListTests.cs
Normal file
549
tests/NATS.Server.Core.Tests/SubListTests.cs
Normal file
@@ -0,0 +1,549 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class SubListTests
|
||||
{
|
||||
private static Subscription MakeSub(string subject, string? queue = null, string sid = "1")
|
||||
=> new() { Subject = subject, Queue = queue, Sid = sid };
|
||||
|
||||
[Fact]
|
||||
public void Insert_and_match_literal_subject()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub("foo.bar");
|
||||
sl.Insert(sub);
|
||||
|
||||
var r = sl.Match("foo.bar");
|
||||
r.PlainSubs.ShouldHaveSingleItem();
|
||||
r.PlainSubs[0].ShouldBeSameAs(sub);
|
||||
r.QueueSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_returns_empty_for_no_match()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("foo.bar"));
|
||||
|
||||
var r = sl.Match("foo.baz");
|
||||
r.PlainSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_partial_wildcard()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub("foo.*");
|
||||
sl.Insert(sub);
|
||||
|
||||
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo.baz").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo.bar.baz").PlainSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_full_wildcard()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub("foo.>");
|
||||
sl.Insert(sub);
|
||||
|
||||
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo.bar.baz").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo").PlainSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_root_full_wildcard()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub(">"));
|
||||
|
||||
sl.Match("foo").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo.bar.baz").PlainSubs.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_collects_multiple_subs()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("foo.bar", sid: "1"));
|
||||
sl.Insert(MakeSub("foo.*", sid: "2"));
|
||||
sl.Insert(MakeSub("foo.>", sid: "3"));
|
||||
sl.Insert(MakeSub(">", sid: "4"));
|
||||
|
||||
var r = sl.Match("foo.bar");
|
||||
r.PlainSubs.Length.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_subscription()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub("foo.bar");
|
||||
sl.Insert(sub);
|
||||
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
||||
|
||||
sl.Remove(sub);
|
||||
sl.Match("foo.bar").PlainSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Queue_group_subscriptions()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("foo.bar", queue: "workers", sid: "1"));
|
||||
sl.Insert(MakeSub("foo.bar", queue: "workers", sid: "2"));
|
||||
sl.Insert(MakeSub("foo.bar", queue: "loggers", sid: "3"));
|
||||
|
||||
var r = sl.Match("foo.bar");
|
||||
r.PlainSubs.ShouldBeEmpty();
|
||||
r.QueueSubs.Length.ShouldBe(2); // 2 queue groups
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Count_tracks_subscriptions()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Count.ShouldBe(0u);
|
||||
|
||||
sl.Insert(MakeSub("foo", sid: "1"));
|
||||
sl.Insert(MakeSub("bar", sid: "2"));
|
||||
sl.Count.ShouldBe(2u);
|
||||
|
||||
sl.Remove(MakeSub("foo", sid: "1"));
|
||||
// Remove by reference won't work — we need the same instance
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Count_tracks_with_same_instance()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub("foo");
|
||||
sl.Insert(sub);
|
||||
sl.Count.ShouldBe(1u);
|
||||
sl.Remove(sub);
|
||||
sl.Count.ShouldBe(0u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cache_invalidation_on_insert()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("foo.bar", sid: "1"));
|
||||
|
||||
// Prime the cache
|
||||
var r1 = sl.Match("foo.bar");
|
||||
r1.PlainSubs.ShouldHaveSingleItem();
|
||||
|
||||
// Insert a wildcard that matches — cache should be invalidated
|
||||
sl.Insert(MakeSub("foo.*", sid: "2"));
|
||||
|
||||
var r2 = sl.Match("foo.bar");
|
||||
r2.PlainSubs.Length.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_partial_wildcard_at_different_levels()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("*.bar.baz", sid: "1"));
|
||||
sl.Insert(MakeSub("foo.*.baz", sid: "2"));
|
||||
sl.Insert(MakeSub("foo.bar.*", sid: "3"));
|
||||
|
||||
var r = sl.Match("foo.bar.baz");
|
||||
r.PlainSubs.Length.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stats_returns_correct_values()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("foo.bar", sid: "1"));
|
||||
sl.Insert(MakeSub("foo.baz", sid: "2"));
|
||||
sl.Match("foo.bar");
|
||||
sl.Match("foo.bar"); // cache hit
|
||||
|
||||
var stats = sl.Stats();
|
||||
stats.NumSubs.ShouldBe(2u);
|
||||
stats.NumInserts.ShouldBe(2ul);
|
||||
stats.NumMatches.ShouldBe(2ul);
|
||||
stats.CacheHitRate.ShouldBeGreaterThan(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasInterest_returns_true_when_subscribers_exist()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("foo.bar"));
|
||||
sl.HasInterest("foo.bar").ShouldBeTrue();
|
||||
sl.HasInterest("foo.baz").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasInterest_with_wildcards()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("foo.*"));
|
||||
sl.HasInterest("foo.bar").ShouldBeTrue();
|
||||
sl.HasInterest("bar.baz").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumInterest_counts_subscribers()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("foo.bar", sid: "1"));
|
||||
sl.Insert(MakeSub("foo.*", sid: "2"));
|
||||
sl.Insert(MakeSub("foo.bar", queue: "q1", sid: "3"));
|
||||
|
||||
var (np, nq) = sl.NumInterest("foo.bar");
|
||||
np.ShouldBe(2); // foo.bar + foo.*
|
||||
nq.ShouldBe(1); // queue sub
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveBatch_removes_all()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub1 = MakeSub("foo.bar", sid: "1");
|
||||
var sub2 = MakeSub("foo.baz", sid: "2");
|
||||
var sub3 = MakeSub("bar.qux", sid: "3");
|
||||
sl.Insert(sub1);
|
||||
sl.Insert(sub2);
|
||||
sl.Insert(sub3);
|
||||
sl.Count.ShouldBe(3u);
|
||||
|
||||
sl.RemoveBatch([sub1, sub2]);
|
||||
sl.Count.ShouldBe(1u);
|
||||
sl.Match("foo.bar").PlainSubs.ShouldBeEmpty();
|
||||
sl.Match("bar.qux").PlainSubs.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_returns_every_subscription()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub1 = MakeSub("foo.bar", sid: "1");
|
||||
var sub2 = MakeSub("foo.*", sid: "2");
|
||||
var sub3 = MakeSub("bar.>", queue: "q", sid: "3");
|
||||
sl.Insert(sub1);
|
||||
sl.Insert(sub2);
|
||||
sl.Insert(sub3);
|
||||
|
||||
var all = sl.All();
|
||||
all.Count.ShouldBe(3);
|
||||
all.ShouldContain(sub1);
|
||||
all.ShouldContain(sub2);
|
||||
all.ShouldContain(sub3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReverseMatch_finds_patterns_matching_literal()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub1 = MakeSub("foo.bar", sid: "1");
|
||||
var sub2 = MakeSub("foo.*", sid: "2");
|
||||
var sub3 = MakeSub("foo.>", sid: "3");
|
||||
var sub4 = MakeSub("bar.baz", sid: "4");
|
||||
sl.Insert(sub1);
|
||||
sl.Insert(sub2);
|
||||
sl.Insert(sub3);
|
||||
sl.Insert(sub4);
|
||||
|
||||
var result = sl.ReverseMatch("foo.bar");
|
||||
result.PlainSubs.Length.ShouldBe(3); // foo.bar, foo.*, foo.>
|
||||
result.PlainSubs.ShouldContain(sub1);
|
||||
result.PlainSubs.ShouldContain(sub2);
|
||||
result.PlainSubs.ShouldContain(sub3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generation_ID_invalidates_cache()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("foo.bar", sid: "1"));
|
||||
|
||||
// Prime cache
|
||||
var r1 = sl.Match("foo.bar");
|
||||
r1.PlainSubs.Length.ShouldBe(1);
|
||||
|
||||
// Insert another sub (bumps generation)
|
||||
sl.Insert(MakeSub("foo.bar", sid: "2"));
|
||||
|
||||
// Cache should be invalidated by generation mismatch
|
||||
var r2 = sl.Match("foo.bar");
|
||||
r2.PlainSubs.Length.ShouldBe(2);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Concurrency and edge case tests
|
||||
// Ported from: golang/nats-server/server/sublist_test.go
|
||||
// TestSublistRaceOnRemove, TestSublistRaceOnInsert, TestSublistRaceOnMatch,
|
||||
// TestSublistRemoveWithLargeSubs, TestSublistInvalidSubjectsInsert,
|
||||
// TestSublistInsertWithWildcardsAsLiterals
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that removing subscriptions concurrently while reading cached
|
||||
/// match results does not corrupt the subscription data. Reads the cached
|
||||
/// result before removals begin and iterates queue entries while removals
|
||||
/// run in parallel.
|
||||
/// Ref: testSublistRaceOnRemove (sublist_test.go:823)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Race_on_remove_does_not_corrupt_cache()
|
||||
{
|
||||
var sl = new SubList();
|
||||
const int total = 100;
|
||||
var subs = new Subscription[total];
|
||||
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
subs[i] = new Subscription { Subject = "foo", Queue = "bar", Sid = i.ToString() };
|
||||
sl.Insert(subs[i]);
|
||||
}
|
||||
|
||||
// Prime cache with one warm-up call then capture result
|
||||
sl.Match("foo");
|
||||
var cached = sl.Match("foo");
|
||||
|
||||
// Start removing all subs concurrently while we inspect the cached result
|
||||
var removeTask = Task.Run(() =>
|
||||
{
|
||||
foreach (var sub in subs)
|
||||
sl.Remove(sub);
|
||||
});
|
||||
|
||||
// Iterate all queue groups in the cached snapshot — must not throw
|
||||
foreach (var qgroup in cached.QueueSubs)
|
||||
{
|
||||
foreach (var sub in qgroup)
|
||||
{
|
||||
sub.Queue.ShouldBe("bar");
|
||||
}
|
||||
}
|
||||
|
||||
await removeTask;
|
||||
|
||||
// After all removals, no interest should remain
|
||||
var afterRemoval = sl.Match("foo");
|
||||
afterRemoval.PlainSubs.ShouldBeEmpty();
|
||||
afterRemoval.QueueSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that inserting subscriptions from one task while another task
|
||||
/// is continuously calling Match does not cause crashes or produce invalid
|
||||
/// results (wrong queue names, corrupted subjects).
|
||||
/// Ref: testSublistRaceOnInsert (sublist_test.go:904)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Race_on_insert_does_not_corrupt_cache()
|
||||
{
|
||||
var sl = new SubList();
|
||||
const int total = 100;
|
||||
var qsubs = new Subscription[total];
|
||||
for (int i = 0; i < total; i++)
|
||||
qsubs[i] = new Subscription { Subject = "foo", Queue = "bar", Sid = i.ToString() };
|
||||
|
||||
// Insert queue subs from background task while matching concurrently
|
||||
var insertTask = Task.Run(() =>
|
||||
{
|
||||
foreach (var sub in qsubs)
|
||||
sl.Insert(sub);
|
||||
});
|
||||
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
var r = sl.Match("foo");
|
||||
foreach (var qgroup in r.QueueSubs)
|
||||
{
|
||||
foreach (var sub in qgroup)
|
||||
sub.Queue.ShouldBe("bar");
|
||||
}
|
||||
}
|
||||
|
||||
await insertTask;
|
||||
|
||||
// Now repeat for plain subs
|
||||
var sl2 = new SubList();
|
||||
var psubs = new Subscription[total];
|
||||
for (int i = 0; i < total; i++)
|
||||
psubs[i] = new Subscription { Subject = "foo", Sid = i.ToString() };
|
||||
|
||||
var insertTask2 = Task.Run(() =>
|
||||
{
|
||||
foreach (var sub in psubs)
|
||||
sl2.Insert(sub);
|
||||
});
|
||||
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
var r = sl2.Match("foo");
|
||||
foreach (var sub in r.PlainSubs)
|
||||
sub.Subject.ShouldBe("foo");
|
||||
}
|
||||
|
||||
await insertTask2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple concurrent goroutines matching the same subject
|
||||
/// simultaneously never observe corrupted subscription data (wrong subjects
|
||||
/// or queue names).
|
||||
/// Ref: TestSublistRaceOnMatch (sublist_test.go:956)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Race_on_match_during_concurrent_mutations()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(new Subscription { Subject = "foo.*", Queue = "workers", Sid = "1" });
|
||||
sl.Insert(new Subscription { Subject = "foo.bar", Queue = "workers", Sid = "2" });
|
||||
sl.Insert(new Subscription { Subject = "foo.*", Sid = "3" });
|
||||
sl.Insert(new Subscription { Subject = "foo.bar", Sid = "4" });
|
||||
|
||||
var errors = new System.Collections.Concurrent.ConcurrentBag<string>();
|
||||
|
||||
async Task MatchRepeatedly()
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var r = sl.Match("foo.bar");
|
||||
foreach (var sub in r.PlainSubs)
|
||||
{
|
||||
if (!sub.Subject.StartsWith("foo.", StringComparison.Ordinal))
|
||||
errors.Add($"Wrong subject: {sub.Subject}");
|
||||
}
|
||||
foreach (var qgroup in r.QueueSubs)
|
||||
{
|
||||
foreach (var sub in qgroup)
|
||||
{
|
||||
if (sub.Queue != "workers")
|
||||
errors.Add($"Wrong queue name: {sub.Queue}");
|
||||
}
|
||||
}
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
await Task.WhenAll(MatchRepeatedly(), MatchRepeatedly());
|
||||
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that removing individual subscriptions from a list that has
|
||||
/// crossed the high-fanout threshold (plistMin=256) produces the correct
|
||||
/// remaining count. Mirrors the Go plistMin*2 scenario.
|
||||
/// Ref: testSublistRemoveWithLargeSubs (sublist_test.go:330)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Remove_from_large_subscription_list()
|
||||
{
|
||||
// plistMin in Go is 256; the .NET port uses 256 as PackedListEnabled threshold.
|
||||
// We use 200 to keep the test fast while still exercising the large-list path.
|
||||
const int subCount = 200;
|
||||
var sl = new SubList();
|
||||
var inserted = new Subscription[subCount];
|
||||
|
||||
for (int i = 0; i < subCount; i++)
|
||||
{
|
||||
inserted[i] = new Subscription { Subject = "foo", Sid = i.ToString() };
|
||||
sl.Insert(inserted[i]);
|
||||
}
|
||||
|
||||
var r = sl.Match("foo");
|
||||
r.PlainSubs.Length.ShouldBe(subCount);
|
||||
|
||||
// Remove one from the middle, one from the start, one from the end
|
||||
sl.Remove(inserted[subCount / 2]);
|
||||
sl.Remove(inserted[0]);
|
||||
sl.Remove(inserted[subCount - 1]);
|
||||
|
||||
var r2 = sl.Match("foo");
|
||||
r2.PlainSubs.Length.ShouldBe(subCount - 3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that attempting to insert subscriptions with invalid subjects
|
||||
/// (empty leading or middle tokens, or a full-wildcard that is not the
|
||||
/// terminal token) causes an ArgumentException to be thrown.
|
||||
/// Note: a trailing dot ("foo.") is not rejected by the current .NET
|
||||
/// TokenEnumerator because the empty token after the trailing separator is
|
||||
/// never yielded — the Go implementation's Insert validates this via a
|
||||
/// separate length check that the .NET port has not yet added.
|
||||
/// Ref: testSublistInvalidSubjectsInsert (sublist_test.go:396)
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(".foo")] // leading empty token — first token is ""
|
||||
[InlineData("foo..bar")] // empty middle token
|
||||
[InlineData("foo.bar..baz")] // empty middle token variant
|
||||
[InlineData("foo.>.bar")] // full-wildcard not terminal
|
||||
public void Insert_invalid_subject_is_rejected(string subject)
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = new Subscription { Subject = subject, Sid = "1" };
|
||||
Should.Throw<ArgumentException>(() => sl.Insert(sub));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that subjects whose tokens contain wildcard characters as part
|
||||
/// of a longer token (e.g. "foo.*-", "foo.>-") are treated as literals and
|
||||
/// do not match via wildcard semantics. The exact subject string matches
|
||||
/// itself, but a plain "foo.bar" does not match.
|
||||
/// Ref: testSublistInsertWithWildcardsAsLiterals (sublist_test.go:775)
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("foo.*-")] // token contains * but is not the single-char wildcard
|
||||
[InlineData("foo.>-")] // token contains > but is not the single-char wildcard
|
||||
public void Wildcards_as_literals_not_matched_as_wildcards(string subject)
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = new Subscription { Subject = subject, Sid = "1" };
|
||||
sl.Insert(sub);
|
||||
|
||||
// A subject that would match if * / > were real wildcards must NOT match
|
||||
sl.Match("foo.bar").PlainSubs.ShouldBeEmpty();
|
||||
|
||||
// The literal subject itself must match exactly
|
||||
sl.Match(subject).PlainSubs.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies edge-case handling for subjects with empty tokens at different
|
||||
/// positions. Empty string, leading dot, and consecutive dots produce no
|
||||
/// match results (the Tokenize helper returns null for invalid subjects).
|
||||
/// Insert with leading or middle empty tokens throws ArgumentException.
|
||||
/// Note: "foo." (trailing dot) is not rejected by Insert because the
|
||||
/// TokenEnumerator stops before yielding the trailing empty token — it is
|
||||
/// a known behavioural gap vs. Go that does not affect correctness of the
|
||||
/// trie but is documented here for future parity work.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Empty_subject_tokens_handled()
|
||||
{
|
||||
var sl = new SubList();
|
||||
|
||||
// Insert a valid sub so the list is not empty
|
||||
sl.Insert(MakeSub("foo.bar", sid: "valid"));
|
||||
|
||||
// Matching against subjects with empty tokens returns no results
|
||||
// (the Match tokenizer returns null / empty for invalid subjects)
|
||||
sl.Match("").PlainSubs.ShouldBeEmpty();
|
||||
sl.Match("foo..bar").PlainSubs.ShouldBeEmpty();
|
||||
sl.Match(".foo").PlainSubs.ShouldBeEmpty();
|
||||
sl.Match("foo.").PlainSubs.ShouldBeEmpty();
|
||||
|
||||
// Inserting a subject with a leading empty token throws
|
||||
Should.Throw<ArgumentException>(() => sl.Insert(new Subscription { Subject = ".foo", Sid = "x" }));
|
||||
// Inserting a subject with a middle empty token throws
|
||||
Should.Throw<ArgumentException>(() => sl.Insert(new Subscription { Subject = "foo..bar", Sid = "x" }));
|
||||
|
||||
// The original valid sub remains unaffected — failed inserts must not corrupt state
|
||||
sl.Count.ShouldBe(1u);
|
||||
sl.Match("foo.bar").PlainSubs.ShouldHaveSingleItem();
|
||||
}
|
||||
}
|
||||
103
tests/NATS.Server.Core.Tests/SubjectMatchTests.cs
Normal file
103
tests/NATS.Server.Core.Tests/SubjectMatchTests.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class SubjectMatchTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("foo", true)]
|
||||
[InlineData("foo.bar", true)]
|
||||
[InlineData("foo.bar.baz", true)]
|
||||
[InlineData("foo.*", true)]
|
||||
[InlineData("foo.>", true)]
|
||||
[InlineData(">", true)]
|
||||
[InlineData("*", true)]
|
||||
[InlineData("*.bar", true)]
|
||||
[InlineData("foo.*.baz", true)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("foo.", false)]
|
||||
[InlineData(".foo", false)]
|
||||
[InlineData("foo..bar", false)]
|
||||
[InlineData("foo.>.bar", false)] // > must be last token
|
||||
[InlineData("foo bar", false)] // no spaces
|
||||
[InlineData("foo\tbar", false)] // no tabs
|
||||
public void IsValidSubject(string subject, bool expected)
|
||||
{
|
||||
SubjectMatch.IsValidSubject(subject).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo", true)]
|
||||
[InlineData("foo.bar.baz", true)]
|
||||
[InlineData("foo.*", false)]
|
||||
[InlineData("foo.>", false)]
|
||||
[InlineData(">", false)]
|
||||
[InlineData("*", false)]
|
||||
public void IsValidPublishSubject(string subject, bool expected)
|
||||
{
|
||||
SubjectMatch.IsValidPublishSubject(subject).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo", "foo", true)]
|
||||
[InlineData("foo", "bar", false)]
|
||||
[InlineData("foo.bar", "foo.*", true)]
|
||||
[InlineData("foo.bar", "*.bar", true)]
|
||||
[InlineData("foo.bar", "*.*", true)]
|
||||
[InlineData("foo.bar.baz", "foo.>", true)]
|
||||
[InlineData("foo.bar.baz", ">", true)]
|
||||
[InlineData("foo.bar", "foo.>", true)]
|
||||
[InlineData("foo", "foo.>", false)]
|
||||
[InlineData("foo.bar.baz", "foo.*", false)]
|
||||
[InlineData("foo.bar", "foo.bar.>", false)]
|
||||
public void MatchLiteral(string literal, string pattern, bool expected)
|
||||
{
|
||||
SubjectMatch.MatchLiteral(literal, pattern).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo.bar.baz", 3)]
|
||||
[InlineData("foo", 1)]
|
||||
[InlineData("a.b.c.d.e", 5)]
|
||||
[InlineData("", 0)]
|
||||
public void NumTokens(string subject, int expected)
|
||||
{
|
||||
SubjectMatch.NumTokens(subject).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo.bar.baz", 0, "foo")]
|
||||
[InlineData("foo.bar.baz", 1, "bar")]
|
||||
[InlineData("foo.bar.baz", 2, "baz")]
|
||||
[InlineData("foo", 0, "foo")]
|
||||
[InlineData("foo.bar.baz", 5, "")]
|
||||
public void TokenAt(string subject, int index, string expected)
|
||||
{
|
||||
SubjectMatch.TokenAt(subject, index).ToString().ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo.bar", "foo.bar", true)]
|
||||
[InlineData("foo.bar", "foo.baz", false)]
|
||||
[InlineData("foo.*", "foo.bar", true)]
|
||||
[InlineData("foo.*", "foo.>", true)]
|
||||
[InlineData("foo.>", "foo.bar.baz", true)]
|
||||
[InlineData(">", "foo.bar", true)]
|
||||
[InlineData("foo.*", "bar.*", false)]
|
||||
[InlineData("foo.*.baz", "foo.bar.*", true)]
|
||||
[InlineData("*.bar", "foo.*", true)]
|
||||
[InlineData("foo.*", "bar.>", false)]
|
||||
public void SubjectsCollide(string subj1, string subj2, bool expected)
|
||||
{
|
||||
SubjectMatch.SubjectsCollide(subj1, subj2).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo\0bar", true, false)]
|
||||
[InlineData("foo\0bar", false, true)]
|
||||
[InlineData("foo.bar", true, true)]
|
||||
public void IsValidSubject_checkRunes(string subject, bool checkRunes, bool expected)
|
||||
{
|
||||
SubjectMatch.IsValidSubject(subject, checkRunes).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user