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:
Joseph Doherty
2026-03-12 16:14:02 -04:00
parent 78b4bc2486
commit 7fbffffd05
114 changed files with 576 additions and 1121 deletions

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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=");
}
}

View 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);
}
}