- 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
861 lines
36 KiB
C#
861 lines
36 KiB
C#
// 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);
|
|
}
|
|
}
|