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:
@@ -0,0 +1,860 @@
|
||||
// Go reference: golang/nats-server/server/client_test.go
|
||||
// Ports specific Go tests that map to existing .NET features:
|
||||
// header stripping, subject/queue parsing, wildcard handling,
|
||||
// message tracing, connection limits, header manipulation,
|
||||
// message parts, and NRG subject rejection.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Subscriptions;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Go parity tests ported from client_test.go for protocol-level behaviors
|
||||
/// covering header stripping, subject/queue parsing, wildcard handling,
|
||||
/// tracing, connection limits, header manipulation, and NRG subjects.
|
||||
/// </summary>
|
||||
public class ClientProtocolGoParityTests
|
||||
{
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers (self-contained per project conventions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private static async Task<string> ReadAllAvailableAsync(Socket sock, int timeoutMs = 1000)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
var sb = new StringBuilder();
|
||||
var buf = new byte[8192];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||||
if (n == 0) break;
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static async Task<(NatsServer Server, int Port, CancellationTokenSource Cts)>
|
||||
StartServerAsync(NatsOptions? options = null)
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options ??= new NatsOptions();
|
||||
options.Port = port;
|
||||
var cts = new CancellationTokenSource();
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, port, cts);
|
||||
}
|
||||
|
||||
private static async Task<Socket> ConnectAndHandshakeAsync(int port, string connectJson = "{}")
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, port);
|
||||
await SocketTestHelper.ReadUntilAsync(sock, "\r\n"); // drain INFO
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"CONNECT {connectJson}\r\n"));
|
||||
return sock;
|
||||
}
|
||||
|
||||
private static async Task<Socket> ConnectAndPingAsync(int port, string connectJson = "{}")
|
||||
{
|
||||
var sock = await ConnectAndHandshakeAsync(port, connectJson);
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n");
|
||||
return sock;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestClientHeaderDeliverStrippedMsg — client_test.go:373
|
||||
// When a subscriber does NOT support headers (no headers:true in CONNECT),
|
||||
// the server must strip headers and deliver a plain MSG with only the payload.
|
||||
// =========================================================================
|
||||
|
||||
[Fact(Skip = "Header stripping for non-header-aware subscribers not yet implemented in .NET server")]
|
||||
public async Task Header_stripped_for_non_header_subscriber()
|
||||
{
|
||||
// Go: TestClientHeaderDeliverStrippedMsg client_test.go:373
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
// Subscriber does NOT advertise headers:true
|
||||
using var sub = await ConnectAndPingAsync(port, "{}");
|
||||
// Publisher DOES advertise headers:true
|
||||
using var pub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
// HPUB foo 12 14\r\nName:Derek\r\nOK\r\n
|
||||
// Header block: "Name:Derek\r\n" = 12 bytes
|
||||
// Payload: "OK" = 2 bytes -> total = 14
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("HPUB foo 12 14\r\nName:Derek\r\nOK\r\n"));
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG\r\n");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
// Non-header subscriber should get a plain MSG with only the payload (2 bytes: "OK")
|
||||
response.ShouldContain("MSG foo 1 2\r\n");
|
||||
response.ShouldContain("OK\r\n");
|
||||
// Should NOT get HMSG
|
||||
response.ShouldNotContain("HMSG");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestClientHeaderDeliverQueueSubStrippedMsg — client_test.go:421
|
||||
// Same as above but with a queue subscription.
|
||||
// =========================================================================
|
||||
|
||||
[Fact(Skip = "Header stripping for non-header-aware subscribers not yet implemented in .NET server")]
|
||||
public async Task Header_stripped_for_non_header_queue_subscriber()
|
||||
{
|
||||
// Go: TestClientHeaderDeliverQueueSubStrippedMsg client_test.go:421
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
// Queue subscriber does NOT advertise headers:true
|
||||
using var sub = await ConnectAndPingAsync(port, "{}");
|
||||
using var pub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
|
||||
// Queue subscription: SUB foo bar 1
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo bar 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("HPUB foo 12 14\r\nName:Derek\r\nOK\r\n"));
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG\r\n");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
// Queue subscriber without headers should get MSG with only payload
|
||||
response.ShouldContain("MSG foo 1 2\r\n");
|
||||
response.ShouldContain("OK\r\n");
|
||||
response.ShouldNotContain("HMSG");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSplitSubjectQueue — client_test.go:811
|
||||
// Tests parsing of subject/queue from "SUB subject [queue] sid" arguments.
|
||||
// This tests SubjectMatch utilities rather than the parser directly.
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo", "foo", null, false)]
|
||||
[InlineData("foo bar", "foo", "bar", false)]
|
||||
[InlineData("foo bar", "foo", "bar", false)]
|
||||
public void SplitSubjectQueue_parses_correctly(string input, string expectedSubject, string? expectedQueue, bool expectError)
|
||||
{
|
||||
// Go: TestSplitSubjectQueue client_test.go:811
|
||||
// The Go test uses splitSubjectQueue which parses the SUB argument line.
|
||||
// In .NET, we validate the same concept via subject parsing logic.
|
||||
var parts = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (expectError)
|
||||
{
|
||||
parts.Length.ShouldBeGreaterThan(2);
|
||||
return;
|
||||
}
|
||||
|
||||
parts[0].ShouldBe(expectedSubject);
|
||||
if (expectedQueue is not null)
|
||||
{
|
||||
parts.Length.ShouldBeGreaterThanOrEqualTo(2);
|
||||
parts[1].ShouldBe(expectedQueue);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitSubjectQueue_extra_tokens_error()
|
||||
{
|
||||
// Go: TestSplitSubjectQueue client_test.go:828 — "foo bar fizz" should error
|
||||
var parts = "foo bar fizz".Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
parts.Length.ShouldBe(3); // three tokens is too many for subject+queue
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestWildcardCharsInLiteralSubjectWorks — client_test.go:1444
|
||||
// Subjects containing * and > that are NOT at token boundaries are treated
|
||||
// as literal characters, not wildcards.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Wildcard_chars_in_literal_subject_work()
|
||||
{
|
||||
// Go: TestWildcardCharsInLiteralSubjectWorks client_test.go:1444
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
using var sock = await ConnectAndPingAsync(port);
|
||||
|
||||
// "foo.bar,*,>,baz" contains *, > but they're NOT at token boundaries
|
||||
// (they're embedded in a comma-delimited token), so they are literal
|
||||
var subj = "foo.bar,*,>,baz";
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"SUB {subj} 1\r\nPUB {subj} 3\r\nmsg\r\nPING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n");
|
||||
|
||||
response.ShouldContain($"MSG {subj} 1 3\r\n");
|
||||
response.ShouldContain("msg\r\n");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestTraceMsg — client_test.go:1700
|
||||
// Tests that trace message formatting truncates correctly.
|
||||
// (Unit test on the traceMsg formatting logic)
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("normal", 10, "normal")]
|
||||
[InlineData("over length", 10, "over lengt")]
|
||||
[InlineData("unlimited length", 0, "unlimited length")]
|
||||
public void TraceMsg_truncation_logic(string msg, int maxLen, string expectedPrefix)
|
||||
{
|
||||
// Go: TestTraceMsg client_test.go:1700
|
||||
// Verifying the truncation logic that would be applied when tracing messages.
|
||||
// In Go: if maxTracedMsgLen > 0 && len(msg) > maxTracedMsgLen, truncate + "..."
|
||||
string result;
|
||||
if (maxLen > 0 && msg.Length > maxLen)
|
||||
result = msg[..maxLen] + "...";
|
||||
else
|
||||
result = msg;
|
||||
|
||||
result.ShouldStartWith(expectedPrefix);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestTraceMsgHeadersOnly — client_test.go:1753
|
||||
// When trace_headers mode is on, only the header portion is traced,
|
||||
// not the payload. Tests the header extraction logic.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void TraceMsgHeadersOnly_extracts_header_portion()
|
||||
{
|
||||
// Go: TestTraceMsgHeadersOnly client_test.go:1753
|
||||
// The Go test verifies that when TraceHeaders is true, only the header
|
||||
// portion up to the terminal \r\n\r\n is traced.
|
||||
var hdr = "NATS/1.0\r\nFoo: 1\r\n\r\n";
|
||||
var payload = "test\r\n";
|
||||
var full = hdr + payload;
|
||||
|
||||
// Extract header portion (everything before the terminal \r\n\r\n)
|
||||
var hdrEnd = full.IndexOf("\r\n\r\n", StringComparison.Ordinal);
|
||||
hdrEnd.ShouldBeGreaterThan(0);
|
||||
|
||||
var headerOnly = full[..hdrEnd];
|
||||
// Replace actual \r\n with escaped for display, matching Go behavior
|
||||
var escaped = headerOnly.Replace("\r\n", "\\r\\n");
|
||||
escaped.ShouldContain("NATS/1.0");
|
||||
escaped.ShouldContain("Foo: 1");
|
||||
escaped.ShouldNotContain("test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TraceMsgHeadersOnly_two_headers_with_max_length()
|
||||
{
|
||||
// Go: TestTraceMsgHeadersOnly client_test.go:1797 — two headers max length
|
||||
var hdr = "NATS/1.0\r\nFoo: 1\r\nBar: 2\r\n\r\n";
|
||||
var hdrEnd = hdr.IndexOf("\r\n\r\n", StringComparison.Ordinal);
|
||||
var headerOnly = hdr[..hdrEnd];
|
||||
var escaped = headerOnly.Replace("\r\n", "\\r\\n");
|
||||
|
||||
// With maxLen=21, should truncate: "NATS/1.0\r\nFoo: 1\r\nBar..."
|
||||
const int maxLen = 21;
|
||||
string result;
|
||||
if (escaped.Length > maxLen)
|
||||
result = escaped[..maxLen] + "...";
|
||||
else
|
||||
result = escaped;
|
||||
|
||||
result.ShouldContain("NATS/1.0");
|
||||
result.ShouldContain("Foo: 1");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestTraceMsgDelivery — client_test.go:1821
|
||||
// End-to-end test: with tracing enabled, messages flow correctly between
|
||||
// publisher and subscriber (the tracing must not break delivery).
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Trace_mode_does_not_break_message_delivery()
|
||||
{
|
||||
// Go: TestTraceMsgDelivery client_test.go:1821
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
using var sub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
using var pub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
// Publish a message with headers
|
||||
var hdr = "NATS/1.0\r\nA: 1\r\nB: 2\r\n\r\n";
|
||||
var payload = "Hello Traced";
|
||||
var totalLen = hdr.Length + payload.Length;
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(
|
||||
$"HPUB foo {hdr.Length} {totalLen}\r\n{hdr}{payload}\r\n"));
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG\r\n");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
response.ShouldContain("HMSG foo 1");
|
||||
response.ShouldContain("Hello Traced");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestTraceMsgDeliveryWithHeaders — client_test.go:1886
|
||||
// Similar to above but specifically validates headers are present in delivery.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Trace_delivery_preserves_headers()
|
||||
{
|
||||
// Go: TestTraceMsgDeliveryWithHeaders client_test.go:1886
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
using var sub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
using var pub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
var hdr = "NATS/1.0\r\nFoo: bar\r\nBaz: qux\r\n\r\n";
|
||||
var payload = "data";
|
||||
var totalLen = hdr.Length + payload.Length;
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes(
|
||||
$"HPUB foo {hdr.Length} {totalLen}\r\n{hdr}{payload}\r\n"));
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG\r\n");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
response.ShouldContain("HMSG foo 1");
|
||||
response.ShouldContain("NATS/1.0");
|
||||
response.ShouldContain("Foo: bar");
|
||||
response.ShouldContain("Baz: qux");
|
||||
response.ShouldContain("data");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestClientLimits — client_test.go:2583
|
||||
// Tests the min-of-three logic: client JWT limit, account limit, server limit.
|
||||
// The effective limit should be the smallest positive value.
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 1, 1, 1)]
|
||||
[InlineData(-1, -1, 0, -1)]
|
||||
[InlineData(1, -1, 0, 1)]
|
||||
[InlineData(-1, 1, 0, 1)]
|
||||
[InlineData(-1, -1, 1, 1)]
|
||||
[InlineData(1, 2, 3, 1)]
|
||||
[InlineData(2, 1, 3, 1)]
|
||||
[InlineData(3, 2, 1, 1)]
|
||||
public void Client_limits_picks_smallest_positive(int client, int acc, int srv, int expected)
|
||||
{
|
||||
// Go: TestClientLimits client_test.go:2583
|
||||
// The effective limit is the smallest positive value among client, account, server.
|
||||
// -1 or 0 means unlimited for that level.
|
||||
var values = new[] { client, acc, srv }.Where(v => v > 0).ToArray();
|
||||
int result = values.Length > 0 ? values.Min() : (client == -1 && acc == -1 ? -1 : 0);
|
||||
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestClientClampMaxSubsErrReport — client_test.go:2645
|
||||
// When max subs is exceeded, the server logs an error. Verify the server
|
||||
// enforces the max subs limit at the protocol level.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task MaxSubs_exceeded_returns_error()
|
||||
{
|
||||
// Go: TestClientClampMaxSubsErrReport client_test.go:2645
|
||||
var (server, port, cts) = await StartServerAsync(new NatsOptions { MaxSubs = 1 });
|
||||
try
|
||||
{
|
||||
using var sock = await ConnectAndPingAsync(port);
|
||||
|
||||
// First sub should succeed
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nPING\r\n"));
|
||||
var r1 = await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n");
|
||||
r1.ShouldNotContain("-ERR");
|
||||
|
||||
// Second sub should exceed the limit
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("SUB bar 2\r\n"));
|
||||
var r2 = await ReadAllAvailableAsync(sock, 3000);
|
||||
r2.ShouldContain("-ERR 'Maximum Subscriptions Exceeded'");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestRemoveHeaderIfPrefixPresent — client_test.go:3158
|
||||
// Tests removal of headers with a given prefix from NATS header block.
|
||||
// This validates the NatsHeaderParser's ability to parse and the concept
|
||||
// of header prefix filtering.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void RemoveHeaderIfPrefixPresent_strips_matching_headers()
|
||||
{
|
||||
// Go: TestRemoveHeaderIfPrefixPresent client_test.go:3158
|
||||
// Build a header block with mixed headers, some with "Nats-Expected-" prefix
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("a: 1\r\n");
|
||||
sb.Append("Nats-Expected-Stream: my-stream\r\n");
|
||||
sb.Append("Nats-Expected-Last-Sequence: 22\r\n");
|
||||
sb.Append("b: 2\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence: 24\r\n");
|
||||
sb.Append("Nats-Expected-Last-Msg-Id: 1\r\n");
|
||||
sb.Append("c: 3\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
|
||||
// After removing headers with prefix "Nats-Expected-", only a, b, c should remain
|
||||
var remaining = headers.Headers
|
||||
.Where(kv => !kv.Key.StartsWith("Nats-Expected-", StringComparison.OrdinalIgnoreCase))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
remaining.ContainsKey("a").ShouldBeTrue();
|
||||
remaining["a"].ShouldBe(["1"]);
|
||||
remaining.ContainsKey("b").ShouldBeTrue();
|
||||
remaining["b"].ShouldBe(["2"]);
|
||||
remaining.ContainsKey("c").ShouldBeTrue();
|
||||
remaining["c"].ShouldBe(["3"]);
|
||||
remaining.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSliceHeader — client_test.go:3176
|
||||
// Tests extracting a specific header value from a NATS header block.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SliceHeader_extracts_specific_header_value()
|
||||
{
|
||||
// Go: TestSliceHeader client_test.go:3176
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("a: 1\r\n");
|
||||
sb.Append("Nats-Expected-Stream: my-stream\r\n");
|
||||
sb.Append("Nats-Expected-Last-Sequence: 22\r\n");
|
||||
sb.Append("b: 2\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence: 24\r\n");
|
||||
sb.Append("Nats-Expected-Last-Msg-Id: 1\r\n");
|
||||
sb.Append("c: 3\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
headers.Headers.TryGetValue("Nats-Expected-Last-Subject-Sequence", out var values).ShouldBeTrue();
|
||||
values!.ShouldBe(["24"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSliceHeaderOrderingPrefix — client_test.go:3199
|
||||
// Headers sharing a prefix must not confuse the parser.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SliceHeader_prefix_ordering_does_not_confuse_parser()
|
||||
{
|
||||
// Go: TestSliceHeaderOrderingPrefix client_test.go:3199
|
||||
// "Nats-Expected-Last-Subject-Sequence-Subject" shares prefix with
|
||||
// "Nats-Expected-Last-Subject-Sequence" — parser must distinguish them.
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence-Subject: foo\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence: 24\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
headers.Headers.TryGetValue("Nats-Expected-Last-Subject-Sequence", out var values).ShouldBeTrue();
|
||||
values!.ShouldBe(["24"]);
|
||||
headers.Headers.TryGetValue("Nats-Expected-Last-Subject-Sequence-Subject", out var subjValues).ShouldBeTrue();
|
||||
subjValues!.ShouldBe(["foo"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSliceHeaderOrderingSuffix — client_test.go:3219
|
||||
// Headers sharing a suffix must not confuse the parser.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SliceHeader_suffix_ordering_does_not_confuse_parser()
|
||||
{
|
||||
// Go: TestSliceHeaderOrderingSuffix client_test.go:3219
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("Previous-Nats-Msg-Id: user\r\n");
|
||||
sb.Append("Nats-Msg-Id: control\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
headers.Headers.TryGetValue("Nats-Msg-Id", out var msgId).ShouldBeTrue();
|
||||
msgId!.ShouldBe(["control"]);
|
||||
headers.Headers.TryGetValue("Previous-Nats-Msg-Id", out var prevId).ShouldBeTrue();
|
||||
prevId!.ShouldBe(["user"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestRemoveHeaderIfPresentOrderingPrefix — client_test.go:3236
|
||||
// Removing a header that shares a prefix with another must not remove both.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void RemoveHeader_prefix_ordering_removes_only_exact_match()
|
||||
{
|
||||
// Go: TestRemoveHeaderIfPresentOrderingPrefix client_test.go:3236
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence-Subject: foo\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence: 24\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
var remaining = headers.Headers
|
||||
.Where(kv => !string.Equals(kv.Key, "Nats-Expected-Last-Subject-Sequence", StringComparison.OrdinalIgnoreCase))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
remaining.Count.ShouldBe(1);
|
||||
remaining.ContainsKey("Nats-Expected-Last-Subject-Sequence-Subject").ShouldBeTrue();
|
||||
remaining["Nats-Expected-Last-Subject-Sequence-Subject"].ShouldBe(["foo"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestRemoveHeaderIfPresentOrderingSuffix — client_test.go:3249
|
||||
// Removing a header that shares a suffix with another must not remove both.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void RemoveHeader_suffix_ordering_removes_only_exact_match()
|
||||
{
|
||||
// Go: TestRemoveHeaderIfPresentOrderingSuffix client_test.go:3249
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("Previous-Nats-Msg-Id: user\r\n");
|
||||
sb.Append("Nats-Msg-Id: control\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
var remaining = headers.Headers
|
||||
.Where(kv => !string.Equals(kv.Key, "Nats-Msg-Id", StringComparison.OrdinalIgnoreCase))
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
remaining.Count.ShouldBe(1);
|
||||
remaining.ContainsKey("Previous-Nats-Msg-Id").ShouldBeTrue();
|
||||
remaining["Previous-Nats-Msg-Id"].ShouldBe(["user"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSetHeaderDoesNotOverwriteUnderlyingBuffer — client_test.go:3283
|
||||
// Setting a header value must not corrupt the message body.
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("Key1", "Val1Updated", "NATS/1.0\r\nKey1: Val1Updated\r\nKey2: Val2\r\n\r\n")]
|
||||
[InlineData("Key1", "v1", "NATS/1.0\r\nKey1: v1\r\nKey2: Val2\r\n\r\n")]
|
||||
[InlineData("Key3", "Val3", "NATS/1.0\r\nKey1: Val1\r\nKey2: Val2\r\nKey3: Val3\r\n\r\n")]
|
||||
public void SetHeader_does_not_overwrite_underlying_buffer(string key, string value, string expectedHdr)
|
||||
{
|
||||
// Go: TestSetHeaderDoesNotOverwriteUnderlyingBuffer client_test.go:3283
|
||||
var initialHdr = "NATS/1.0\r\nKey1: Val1\r\nKey2: Val2\r\n\r\n";
|
||||
var msgBody = "this is the message body\r\n";
|
||||
|
||||
// Parse the initial header
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(initialHdr));
|
||||
|
||||
// Modify the header
|
||||
var mutableHeaders = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kv in headers.Headers)
|
||||
mutableHeaders[kv.Key] = [.. kv.Value];
|
||||
|
||||
if (mutableHeaders.ContainsKey(key))
|
||||
mutableHeaders[key] = [value];
|
||||
else
|
||||
mutableHeaders[key] = [value];
|
||||
|
||||
// Rebuild header block
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
foreach (var kv in mutableHeaders.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var v in kv.Value)
|
||||
sb.Append($"{kv.Key}: {v}\r\n");
|
||||
}
|
||||
sb.Append("\r\n");
|
||||
|
||||
var rebuiltHdr = sb.ToString();
|
||||
|
||||
// Parse the expected header to verify structure
|
||||
var expectedParsed = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(expectedHdr));
|
||||
var rebuiltParsed = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(rebuiltHdr));
|
||||
|
||||
rebuiltParsed.Headers[key].ShouldBe([value]);
|
||||
// The message body should not be affected
|
||||
msgBody.ShouldBe("this is the message body\r\n");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSetHeaderOrderingPrefix — client_test.go:3321
|
||||
// Setting a header that shares a prefix with another must update the correct one.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SetHeader_prefix_ordering_updates_correct_header()
|
||||
{
|
||||
// Go: TestSetHeaderOrderingPrefix client_test.go:3321
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence-Subject: foo\r\n");
|
||||
sb.Append("Nats-Expected-Last-Subject-Sequence: 24\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
|
||||
// Verify the shorter-named header has correct value
|
||||
headers.Headers.TryGetValue("Nats-Expected-Last-Subject-Sequence", out var values).ShouldBeTrue();
|
||||
values!.ShouldBe(["24"]);
|
||||
|
||||
// The longer-named header should be unaffected
|
||||
headers.Headers.TryGetValue("Nats-Expected-Last-Subject-Sequence-Subject", out var subjValues).ShouldBeTrue();
|
||||
subjValues!.ShouldBe(["foo"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestSetHeaderOrderingSuffix — client_test.go:3349
|
||||
// Setting a header that shares a suffix with another must update the correct one.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SetHeader_suffix_ordering_updates_correct_header()
|
||||
{
|
||||
// Go: TestSetHeaderOrderingSuffix client_test.go:3349
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("NATS/1.0\r\n");
|
||||
sb.Append("Previous-Nats-Msg-Id: user\r\n");
|
||||
sb.Append("Nats-Msg-Id: control\r\n");
|
||||
sb.Append("\r\n");
|
||||
|
||||
var headers = NatsHeaderParser.Parse(Encoding.ASCII.GetBytes(sb.ToString()));
|
||||
|
||||
headers.Headers.TryGetValue("Nats-Msg-Id", out var msgIdValues).ShouldBeTrue();
|
||||
msgIdValues!.ShouldBe(["control"]);
|
||||
headers.Headers.TryGetValue("Previous-Nats-Msg-Id", out var prevValues).ShouldBeTrue();
|
||||
prevValues!.ShouldBe(["user"]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestMsgPartsCapsHdrSlice — client_test.go:3262
|
||||
// The header and message body parts must be independent slices;
|
||||
// appending to the header must not corrupt the body.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void MsgParts_header_and_body_independent()
|
||||
{
|
||||
// Go: TestMsgPartsCapsHdrSlice client_test.go:3262
|
||||
var hdrContent = "NATS/1.0\r\nKey1: Val1\r\nKey2: Val2\r\n\r\n";
|
||||
var msgBody = "hello\r\n";
|
||||
var combined = hdrContent + msgBody;
|
||||
|
||||
// Split into header and body
|
||||
var hdrEnd = combined.IndexOf("\r\n\r\n", StringComparison.Ordinal) + 4;
|
||||
var hdrPart = combined[..hdrEnd];
|
||||
var bodyPart = combined[hdrEnd..];
|
||||
|
||||
hdrPart.ShouldBe(hdrContent);
|
||||
bodyPart.ShouldBe(msgBody);
|
||||
|
||||
// Appending to hdrPart should not affect bodyPart
|
||||
var extendedHdr = hdrPart + "test";
|
||||
extendedHdr.ShouldBe(hdrContent + "test");
|
||||
bodyPart.ShouldBe("hello\r\n");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TestClientRejectsNRGSubjects — client_test.go:3540
|
||||
// Non-system clients must be rejected when publishing to $NRG.* subjects.
|
||||
// =========================================================================
|
||||
|
||||
[Fact(Skip = "$NRG subject rejection for non-system clients not yet implemented in .NET server")]
|
||||
public async Task Client_rejects_NRG_subjects_for_non_system_users()
|
||||
{
|
||||
// Go: TestClientRejectsNRGSubjects client_test.go:3540
|
||||
// Normal (non-system) clients should get a permissions violation when
|
||||
// trying to publish to $NRG.* subjects.
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
using var sock = await ConnectAndPingAsync(port);
|
||||
|
||||
// Attempt to publish to an NRG subject
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes("PUB $NRG.foo 0\r\n\r\nPING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n", timeoutMs: 5000);
|
||||
|
||||
// The server should reject this with a permissions violation
|
||||
// (In Go, non-system clients get a publish permission error for $NRG.*)
|
||||
response.ShouldContain("-ERR");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Additional header stripping tests — header subscriber gets HMSG
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Header_subscriber_receives_HMSG_with_full_headers()
|
||||
{
|
||||
// Go: TestClientHeaderDeliverMsg client_test.go:330
|
||||
// When the subscriber DOES support headers, it should get the full HMSG.
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
using var sub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
using var pub = await ConnectAndPingAsync(port, "{\"headers\":true}");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("HPUB foo 12 14\r\nName:Derek\r\nOK\r\n"));
|
||||
await pub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(pub, "PONG\r\n");
|
||||
|
||||
await sub.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sub, "PONG\r\n");
|
||||
|
||||
// Header-aware subscriber should get HMSG with full headers
|
||||
response.ShouldContain("HMSG foo 1 12 14\r\n");
|
||||
response.ShouldContain("Name:Derek");
|
||||
response.ShouldContain("OK");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Wildcard in literal subject — second subscribe/unsubscribe cycle
|
||||
// Go: TestWildcardCharsInLiteralSubjectWorks client_test.go:1462
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Wildcard_chars_in_literal_subject_survive_unsub_resub()
|
||||
{
|
||||
// Go: TestWildcardCharsInLiteralSubjectWorks client_test.go:1462
|
||||
// The Go test does two iterations: subscribe, publish, receive, unsubscribe.
|
||||
var (server, port, cts) = await StartServerAsync();
|
||||
try
|
||||
{
|
||||
using var sock = await ConnectAndPingAsync(port);
|
||||
|
||||
var subj = "foo.bar,*,>,baz";
|
||||
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"SUB {subj} {i + 1}\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n");
|
||||
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"PUB {subj} 3\r\nmsg\r\nPING\r\n"));
|
||||
var response = await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n");
|
||||
response.ShouldContain($"MSG {subj} {i + 1} 3\r\n");
|
||||
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"UNSUB {i + 1}\r\nPING\r\n"));
|
||||
await SocketTestHelper.ReadUntilAsync(sock, "PONG\r\n");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Priority group name regex validation
|
||||
// Go: TestPriorityGroupNameRegex consumer.go:49 — ^[a-zA-Z0-9/_=-]{1,16}$
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("A", true)]
|
||||
[InlineData("group/consumer=A", true)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("A B", false)]
|
||||
[InlineData("A\tB", false)]
|
||||
[InlineData("group-name-that-is-too-long", false)]
|
||||
[InlineData("\r\n", false)]
|
||||
public void PriorityGroupNameRegex_validates_correctly(string group, bool expected)
|
||||
{
|
||||
// Go: TestPriorityGroupNameRegex jetstream_consumer_test.go:2584
|
||||
// Go regex: ^[a-zA-Z0-9/_=-]{1,16}$
|
||||
var pattern = new Regex(@"^[a-zA-Z0-9/_=\-]{1,16}$");
|
||||
pattern.IsMatch(group).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class InterServerOpcodeRoutingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parser_dispatch_rejects_Aplus_for_client_kind_client_but_allows_for_gateway()
|
||||
{
|
||||
var m = new ClientCommandMatrix();
|
||||
m.IsAllowed(ClientKind.Client, "A+").ShouldBeFalse();
|
||||
m.IsAllowed(ClientKind.Gateway, "A+").ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
public class MessageTraceInitializationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Trace_context_is_initialized_from_connect_options()
|
||||
{
|
||||
var connectOpts = new ClientOptions
|
||||
{
|
||||
Name = "c1",
|
||||
Lang = "dotnet",
|
||||
Version = "1.0.0",
|
||||
Headers = true,
|
||||
};
|
||||
|
||||
var ctx = MessageTraceContext.CreateFromConnect(connectOpts);
|
||||
ctx.ClientName.ShouldBe("c1");
|
||||
ctx.ClientLang.ShouldBe("dotnet");
|
||||
ctx.ClientVersion.ShouldBe("1.0.0");
|
||||
ctx.HeadersEnabled.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests.ProtocolParity;
|
||||
|
||||
public class ProtoWireParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void ScanField_reads_tag_and_value_size_for_length_delimited_field()
|
||||
{
|
||||
// field=2, type=2, len=3, bytes=abc
|
||||
byte[] bytes = [0x12, 0x03, (byte)'a', (byte)'b', (byte)'c'];
|
||||
|
||||
var (number, wireType, size) = ProtoWire.ScanField(bytes);
|
||||
|
||||
number.ShouldBe(2);
|
||||
wireType.ShouldBe(2);
|
||||
size.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanTag_rejects_invalid_field_numbers()
|
||||
{
|
||||
var zeroFieldEx = Should.Throw<ProtoWireException>(() => ProtoWire.ScanTag([0x00]));
|
||||
zeroFieldEx.Message.ShouldBe(ProtoWire.ErrProtoInvalidFieldNumber);
|
||||
|
||||
var tooLargeTag = ProtoWire.EncodeVarint(((ulong)int.MaxValue + 1UL) << 3);
|
||||
var tooLargeEx = Should.Throw<ProtoWireException>(() => ProtoWire.ScanTag(tooLargeTag));
|
||||
tooLargeEx.Message.ShouldBe(ProtoWire.ErrProtoInvalidFieldNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFieldValue_supports_expected_wire_types()
|
||||
{
|
||||
ProtoWire.ScanFieldValue(5, [0, 0, 0, 0]).ShouldBe(4);
|
||||
ProtoWire.ScanFieldValue(1, [0, 0, 0, 0, 0, 0, 0, 0]).ShouldBe(8);
|
||||
ProtoWire.ScanFieldValue(0, [0x01]).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanFieldValue_rejects_unsupported_wire_type()
|
||||
{
|
||||
var ex = Should.Throw<ProtoWireException>(() => ProtoWire.ScanFieldValue(3, [0x00]));
|
||||
ex.Message.ShouldBe("unsupported type: 3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanVarint_reports_insufficient_and_overflow_errors()
|
||||
{
|
||||
var insufficient = Should.Throw<ProtoWireException>(() => ProtoWire.ScanVarint([0x80]));
|
||||
insufficient.Message.ShouldBe(ProtoWire.ErrProtoInsufficient);
|
||||
|
||||
byte[] overflow = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x02];
|
||||
var tooLarge = Should.Throw<ProtoWireException>(() => ProtoWire.ScanVarint(overflow));
|
||||
tooLarge.Message.ShouldBe(ProtoWire.ErrProtoOverflow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScanBytes_reports_insufficient_when_length_prefix_exceeds_payload()
|
||||
{
|
||||
var ex = Should.Throw<ProtoWireException>(() => ProtoWire.ScanBytes([0x04, 0x01, 0x02]));
|
||||
ex.Message.ShouldBe(ProtoWire.ErrProtoInsufficient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeVarint_round_trips_values_via_scan_varint()
|
||||
{
|
||||
ulong[] values =
|
||||
[
|
||||
0UL,
|
||||
1UL,
|
||||
127UL,
|
||||
128UL,
|
||||
16_383UL,
|
||||
16_384UL,
|
||||
(1UL << 32) - 1,
|
||||
ulong.MaxValue,
|
||||
];
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
var encoded = ProtoWire.EncodeVarint(value);
|
||||
var (decoded, size) = ProtoWire.ScanVarint(encoded);
|
||||
decoded.ShouldBe(value);
|
||||
size.ShouldBe(encoded.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests.ProtocolParity;
|
||||
|
||||
public class ProtocolDefaultConstantsGapParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void NatsProtocol_exposes_core_default_constants()
|
||||
{
|
||||
NatsProtocol.DefaultHost.ShouldBe("0.0.0.0");
|
||||
NatsProtocol.DefaultHttpPort.ShouldBe(8222);
|
||||
NatsProtocol.DefaultHttpBasePath.ShouldBe("/");
|
||||
NatsProtocol.DefaultRoutePoolSize.ShouldBe(3);
|
||||
NatsProtocol.DefaultLeafNodePort.ShouldBe(7422);
|
||||
NatsProtocol.MaxPayloadMaxSize.ShouldBe(8 * 1024 * 1024);
|
||||
NatsProtocol.DefaultMaxConnections.ShouldBe(64 * 1024);
|
||||
NatsProtocol.DefaultPingMaxOut.ShouldBe(2);
|
||||
NatsProtocol.DefaultMaxClosedClients.ShouldBe(10_000);
|
||||
NatsProtocol.DefaultConnectErrorReports.ShouldBe(3600);
|
||||
NatsProtocol.DefaultReconnectErrorReports.ShouldBe(1);
|
||||
NatsProtocol.DefaultAllowResponseMaxMsgs.ShouldBe(1);
|
||||
NatsProtocol.DefaultServiceLatencySampling.ShouldBe(100);
|
||||
NatsProtocol.DefaultSystemAccount.ShouldBe("$SYS");
|
||||
NatsProtocol.DefaultGlobalAccount.ShouldBe("$G");
|
||||
NatsProtocol.ProtoSnippetSize.ShouldBe(32);
|
||||
NatsProtocol.MaxControlLineSnippetSize.ShouldBe(128);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsProtocol_exposes_core_default_timespans()
|
||||
{
|
||||
NatsProtocol.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
NatsProtocol.DefaultTlsHandshakeFirstFallbackDelay.ShouldBe(TimeSpan.FromMilliseconds(50));
|
||||
NatsProtocol.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
NatsProtocol.DefaultRouteConnect.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultRouteConnectMax.ShouldBe(TimeSpan.FromSeconds(30));
|
||||
NatsProtocol.DefaultRouteReconnect.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultRouteDial.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultLeafNodeReconnect.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultLeafTlsTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
NatsProtocol.DefaultLeafNodeInfoWait.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultRttMeasurementInterval.ShouldBe(TimeSpan.FromHours(1));
|
||||
NatsProtocol.DefaultAllowResponseExpiration.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
NatsProtocol.DefaultServiceExportResponseThreshold.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
NatsProtocol.DefaultAccountFetchTimeout.ShouldBe(TimeSpan.FromMilliseconds(1900));
|
||||
NatsProtocol.DefaultPingInterval.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
NatsProtocol.DefaultFlushDeadline.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
NatsProtocol.AcceptMinSleep.ShouldBe(TimeSpan.FromMilliseconds(10));
|
||||
NatsProtocol.AcceptMaxSleep.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
NatsProtocol.DefaultLameDuckDuration.ShouldBe(TimeSpan.FromMinutes(2));
|
||||
NatsProtocol.DefaultLameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsOptions_defaults_are_bound_to_protocol_defaults()
|
||||
{
|
||||
var options = new NatsOptions();
|
||||
|
||||
options.Host.ShouldBe(NatsProtocol.DefaultHost);
|
||||
options.Port.ShouldBe(NatsProtocol.DefaultPort);
|
||||
options.MaxConnections.ShouldBe(NatsProtocol.DefaultMaxConnections);
|
||||
options.AuthTimeout.ShouldBe(NatsProtocol.AuthTimeout);
|
||||
options.PingInterval.ShouldBe(NatsProtocol.DefaultPingInterval);
|
||||
options.MaxPingsOut.ShouldBe(NatsProtocol.DefaultPingMaxOut);
|
||||
options.WriteDeadline.ShouldBe(NatsProtocol.DefaultFlushDeadline);
|
||||
options.TlsTimeout.ShouldBe(NatsProtocol.TlsTimeout);
|
||||
options.TlsHandshakeFirstFallback.ShouldBe(NatsProtocol.DefaultTlsHandshakeFirstFallbackDelay);
|
||||
options.MaxClosedClients.ShouldBe(NatsProtocol.DefaultMaxClosedClients);
|
||||
options.LameDuckDuration.ShouldBe(NatsProtocol.DefaultLameDuckDuration);
|
||||
options.LameDuckGracePeriod.ShouldBe(NatsProtocol.DefaultLameDuckGracePeriod);
|
||||
options.ConnectErrorReports.ShouldBe(NatsProtocol.DefaultConnectErrorReports);
|
||||
options.ReconnectErrorReports.ShouldBe(NatsProtocol.DefaultReconnectErrorReports);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Buffers;
|
||||
using System.Text;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests.ProtocolParity;
|
||||
|
||||
public class ProtocolParserSnippetGapParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProtoSnippet_returns_empty_quotes_when_start_is_out_of_range()
|
||||
{
|
||||
var bytes = "PING"u8.ToArray();
|
||||
var snippet = NatsParser.ProtoSnippet(bytes.Length, 2, bytes);
|
||||
snippet.ShouldBe("\"\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtoSnippet_limits_to_requested_window_and_quotes_output()
|
||||
{
|
||||
var bytes = "ABCDEFGHIJ"u8.ToArray();
|
||||
var snippet = NatsParser.ProtoSnippet(2, 4, bytes);
|
||||
snippet.ShouldBe("\"CDEF\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtoSnippet_matches_go_behavior_when_max_runs_past_buffer_end()
|
||||
{
|
||||
var bytes = "ABCDE"u8.ToArray();
|
||||
var snippet = NatsParser.ProtoSnippet(0, 32, bytes);
|
||||
snippet.ShouldBe("\"ABCD\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_exceeding_max_control_line_includes_snippet_context_in_error()
|
||||
{
|
||||
var parser = new NatsParser();
|
||||
var longSubject = new string('a', NatsProtocol.MaxControlLineSize + 1);
|
||||
var input = Encoding.ASCII.GetBytes($"PUB {longSubject} 0\r\n\r\n");
|
||||
ReadOnlySequence<byte> buffer = new(input);
|
||||
|
||||
var ex = Should.Throw<ProtocolViolationException>(() => parser.TryParse(ref buffer, out _));
|
||||
ex.Message.ShouldContain("Maximum control line exceeded");
|
||||
ex.Message.ShouldContain("snip=");
|
||||
}
|
||||
}
|
||||
514
tests/NATS.Server.Core.Tests/Protocol/ProxyProtocolTests.cs
Normal file
514
tests/NATS.Server.Core.Tests/Protocol/ProxyProtocolTests.cs
Normal file
@@ -0,0 +1,514 @@
|
||||
// Go reference: golang/nats-server/server/client_proxyproto_test.go
|
||||
// Ports the PROXY protocol v1 and v2 parsing tests from the Go implementation.
|
||||
// The Go implementation uses a mock net.Conn; here we work directly with byte
|
||||
// buffers via the pure-parser surface ProxyProtocolParser.
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PROXY protocol v1/v2 parser tests.
|
||||
/// Ported from golang/nats-server/server/client_proxyproto_test.go.
|
||||
/// </summary>
|
||||
public class ProxyProtocolTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Build helpers (mirror the Go buildProxy* helpers)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Wraps the static builder for convenience inside tests.</summary>
|
||||
private static byte[] BuildV2Header(
|
||||
string srcIp, string dstIp, ushort srcPort, ushort dstPort, bool ipv6 = false)
|
||||
=> ProxyProtocolParser.BuildV2Header(srcIp, dstIp, srcPort, dstPort, ipv6);
|
||||
|
||||
private static byte[] BuildV2LocalHeader()
|
||||
=> ProxyProtocolParser.BuildV2LocalHeader();
|
||||
|
||||
private static byte[] BuildV1Header(
|
||||
string protocol, string srcIp = "", string dstIp = "", ushort srcPort = 0, ushort dstPort = 0)
|
||||
=> ProxyProtocolParser.BuildV1Header(protocol, srcIp, dstIp, srcPort, dstPort);
|
||||
|
||||
// =========================================================================
|
||||
// PROXY protocol v2 tests
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Parses a well-formed v2 PROXY header carrying an IPv4 source address and
|
||||
/// verifies that the extracted src/dst IP, port, and network string are correct.
|
||||
/// Ref: TestClientProxyProtoV2ParseIPv4 (client_proxyproto_test.go:155)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_parses_IPv4_address()
|
||||
{
|
||||
var header = BuildV2Header("192.168.1.50", "10.0.0.1", 12345, 4222);
|
||||
var result = ProxyProtocolParser.Parse(header);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address.ShouldNotBeNull();
|
||||
result.Address.SrcIp.ToString().ShouldBe("192.168.1.50");
|
||||
result.Address.SrcPort.ShouldBe((ushort)12345);
|
||||
result.Address.DstIp.ToString().ShouldBe("10.0.0.1");
|
||||
result.Address.DstPort.ShouldBe((ushort)4222);
|
||||
result.Address.ToString().ShouldBe("192.168.1.50:12345");
|
||||
result.Address.Network.ShouldBe("tcp4");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a well-formed v2 PROXY header carrying an IPv6 source address and
|
||||
/// verifies that the extracted src/dst IP, port, and network string are correct.
|
||||
/// Ref: TestClientProxyProtoV2ParseIPv6 (client_proxyproto_test.go:174)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_parses_IPv6_address()
|
||||
{
|
||||
var header = BuildV2Header("2001:db8::1", "2001:db8::2", 54321, 4222, ipv6: true);
|
||||
var result = ProxyProtocolParser.Parse(header);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address.ShouldNotBeNull();
|
||||
result.Address.SrcIp.ToString().ShouldBe("2001:db8::1");
|
||||
result.Address.SrcPort.ShouldBe((ushort)54321);
|
||||
result.Address.DstIp.ToString().ShouldBe("2001:db8::2");
|
||||
result.Address.DstPort.ShouldBe((ushort)4222);
|
||||
result.Address.ToString().ShouldBe("[2001:db8::1]:54321");
|
||||
result.Address.Network.ShouldBe("tcp6");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A LOCAL command header (health check) must parse successfully and return
|
||||
/// a Local result with no address.
|
||||
/// Ref: TestClientProxyProtoV2ParseLocalCommand (client_proxyproto_test.go:193)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_LOCAL_command_returns_local_result()
|
||||
{
|
||||
var header = BuildV2LocalHeader();
|
||||
var result = ProxyProtocolParser.Parse(header);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Local);
|
||||
result.Address.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v2 header with an invalid 12-byte signature must throw
|
||||
/// <see cref="ProxyProtocolException"/>. The test calls <see cref="ProxyProtocolParser.ParseV2"/>
|
||||
/// directly so the full-signature check is exercised (auto-detection would classify the
|
||||
/// buffer as "unrecognized" before reaching the signature comparison).
|
||||
/// Ref: TestClientProxyProtoV2InvalidSignature (client_proxyproto_test.go:202)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_invalid_signature_throws()
|
||||
{
|
||||
// Build a 16-byte buffer whose first 12 bytes are garbage — ParseV2 must
|
||||
// reject it because the full signature comparison fails.
|
||||
var header = new byte[16];
|
||||
Encoding.ASCII.GetBytes("INVALID_SIG_").CopyTo(header, 0);
|
||||
header[12] = 0x20; // ver/cmd
|
||||
header[13] = 0x11; // fam/proto
|
||||
header[14] = 0x00;
|
||||
header[15] = 0x0C;
|
||||
|
||||
// Use ParseV2 directly — this validates the complete 12-byte signature.
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.ParseV2(header));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v2 header where the version nibble is not 2 must be rejected.
|
||||
/// Ref: TestClientProxyProtoV2InvalidVersion (client_proxyproto_test.go:212)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_invalid_version_nibble_throws()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A")); // valid sig
|
||||
ms.WriteByte(0x10 | 0x01); // version = 1 (wrong), command = PROXY
|
||||
ms.WriteByte(0x10 | 0x01); // family = IPv4, proto = STREAM
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x00);
|
||||
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.ParseV2(ms.ToArray()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v2 PROXY command with the Unix socket address family must be rejected
|
||||
/// with an unsupported-feature exception.
|
||||
/// Ref: TestClientProxyProtoV2UnsupportedFamily (client_proxyproto_test.go:226)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_unix_socket_family_is_unsupported()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"));
|
||||
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
|
||||
ms.WriteByte(0x30 | 0x01); // family=Unix, proto=STREAM
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x00);
|
||||
|
||||
Should.Throw<ProxyProtocolUnsupportedException>(() => ProxyProtocolParser.ParseV2(ms.ToArray()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v2 PROXY command with the UDP (Datagram) protocol must be rejected
|
||||
/// with an unsupported-feature exception.
|
||||
/// Ref: TestClientProxyProtoV2UnsupportedProtocol (client_proxyproto_test.go:240)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_datagram_protocol_is_unsupported()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"));
|
||||
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
|
||||
ms.WriteByte(0x10 | 0x02); // family=IPv4, proto=DATAGRAM (UDP)
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x0C); // addr-len = 12
|
||||
|
||||
Should.Throw<ProxyProtocolUnsupportedException>(() => ProxyProtocolParser.ParseV2(ms.ToArray()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A truncated v2 header (only 10 of the required 16 bytes) must throw.
|
||||
/// Ref: TestClientProxyProtoV2TruncatedHeader (client_proxyproto_test.go:254)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_truncated_header_throws()
|
||||
{
|
||||
var full = BuildV2Header("192.168.1.50", "10.0.0.1", 12345, 4222);
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(full[..10]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v2 header whose address-length field says 12 bytes but the buffer
|
||||
/// supplies only 5 bytes must throw.
|
||||
/// Ref: TestClientProxyProtoV2ShortAddressData (client_proxyproto_test.go:263)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_short_address_data_throws()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"));
|
||||
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
|
||||
ms.WriteByte(0x10 | 0x01); // family=IPv4, proto=STREAM
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x0C); // addr-len = 12
|
||||
// Write only 5 bytes of address data instead of 12
|
||||
ms.Write(new byte[] { 1, 2, 3, 4, 5 });
|
||||
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.ParseV2(ms.ToArray()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ProxyAddress.ToString() returns "ip:port" for IPv4 and "[ip]:port" for IPv6;
|
||||
/// ProxyAddress.Network() returns "tcp4" or "tcp6" accordingly.
|
||||
/// Ref: TestProxyConnRemoteAddr (client_proxyproto_test.go:280)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ProxyAddress_string_and_network_are_correct()
|
||||
{
|
||||
var ipv4Addr = new ProxyAddress
|
||||
{
|
||||
SrcIp = IPAddress.Parse("10.0.0.50"),
|
||||
SrcPort = 12345,
|
||||
DstIp = IPAddress.Parse("10.0.0.1"),
|
||||
DstPort = 4222,
|
||||
};
|
||||
ipv4Addr.ToString().ShouldBe("10.0.0.50:12345");
|
||||
ipv4Addr.Network.ShouldBe("tcp4");
|
||||
|
||||
var ipv6Addr = new ProxyAddress
|
||||
{
|
||||
SrcIp = IPAddress.Parse("2001:db8::1"),
|
||||
SrcPort = 54321,
|
||||
DstIp = IPAddress.Parse("2001:db8::2"),
|
||||
DstPort = 4222,
|
||||
};
|
||||
ipv6Addr.ToString().ShouldBe("[2001:db8::1]:54321");
|
||||
ipv6Addr.Network.ShouldBe("tcp6");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PROXY protocol v1 tests
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// A well-formed TCP4 v1 header is parsed and the source address is returned.
|
||||
/// Ref: TestClientProxyProtoV1ParseTCP4 (client_proxyproto_test.go:416)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_parses_TCP4_address()
|
||||
{
|
||||
var header = BuildV1Header("TCP4", "192.168.1.50", "10.0.0.1", 12345, 4222);
|
||||
var result = ProxyProtocolParser.Parse(header);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address.ShouldNotBeNull();
|
||||
result.Address.SrcIp.ToString().ShouldBe("192.168.1.50");
|
||||
result.Address.SrcPort.ShouldBe((ushort)12345);
|
||||
result.Address.DstIp.ToString().ShouldBe("10.0.0.1");
|
||||
result.Address.DstPort.ShouldBe((ushort)4222);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A well-formed TCP6 v1 header is parsed and the source IPv6 address is returned.
|
||||
/// Ref: TestClientProxyProtoV1ParseTCP6 (client_proxyproto_test.go:431)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_parses_TCP6_address()
|
||||
{
|
||||
var header = BuildV1Header("TCP6", "2001:db8::1", "2001:db8::2", 54321, 4222);
|
||||
var result = ProxyProtocolParser.Parse(header);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address.ShouldNotBeNull();
|
||||
result.Address.SrcIp.ToString().ShouldBe("2001:db8::1");
|
||||
result.Address.SrcPort.ShouldBe((ushort)54321);
|
||||
result.Address.DstIp.ToString().ShouldBe("2001:db8::2");
|
||||
result.Address.DstPort.ShouldBe((ushort)4222);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An UNKNOWN v1 header (health check) must return a Local result with no address.
|
||||
/// Ref: TestClientProxyProtoV1ParseUnknown (client_proxyproto_test.go:446)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_UNKNOWN_returns_local_result()
|
||||
{
|
||||
var header = BuildV1Header("UNKNOWN");
|
||||
var result = ProxyProtocolParser.Parse(header);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Local);
|
||||
result.Address.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v1 header with too few fields (e.g. missing port tokens) must throw.
|
||||
/// Ref: TestClientProxyProtoV1InvalidFormat (client_proxyproto_test.go:455)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_missing_fields_throws()
|
||||
{
|
||||
// "PROXY TCP4 192.168.1.1\r\n" — only 1 token after PROXY
|
||||
var header = Encoding.ASCII.GetBytes("PROXY TCP4 192.168.1.1\r\n");
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v1 line longer than 107 bytes without a CRLF must throw.
|
||||
/// Ref: TestClientProxyProtoV1LineTooLong (client_proxyproto_test.go:464)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_line_too_long_throws()
|
||||
{
|
||||
var longIp = new string('1', 120);
|
||||
var header = Encoding.ASCII.GetBytes($"PROXY TCP4 {longIp} 10.0.0.1 12345 443\r\n");
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v1 header whose IP token is not a parseable IP address must throw.
|
||||
/// Ref: TestClientProxyProtoV1InvalidIP (client_proxyproto_test.go:474)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_invalid_IP_address_throws()
|
||||
{
|
||||
var header = Encoding.ASCII.GetBytes("PROXY TCP4 not.an.ip.addr 10.0.0.1 12345 443\r\n");
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TCP4 protocol with an IPv6 source address, and TCP6 protocol with an IPv4
|
||||
/// source address, must both throw a protocol-mismatch exception.
|
||||
/// Ref: TestClientProxyProtoV1MismatchedProtocol (client_proxyproto_test.go:482)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_TCP4_with_IPv6_address_throws()
|
||||
{
|
||||
var header = BuildV1Header("TCP4", "2001:db8::1", "2001:db8::2", 12345, 443);
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void V1_TCP6_with_IPv4_address_throws()
|
||||
{
|
||||
var header = BuildV1Header("TCP6", "192.168.1.1", "10.0.0.1", 12345, 443);
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A port value that exceeds 65535 cannot be parsed as ushort and must throw.
|
||||
/// Ref: TestClientProxyProtoV1InvalidPort (client_proxyproto_test.go:498)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V1_port_out_of_range_throws()
|
||||
{
|
||||
var header = Encoding.ASCII.GetBytes("PROXY TCP4 192.168.1.1 10.0.0.1 99999 443\r\n");
|
||||
Should.Throw<Exception>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Mixed version detection tests
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// The auto-detection logic correctly routes a "PROXY " prefix to the v1 parser
|
||||
/// and a binary v2 signature to the v2 parser, extracting the correct source address.
|
||||
/// Ref: TestClientProxyProtoVersionDetection (client_proxyproto_test.go:567)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Auto_detection_routes_v1_and_v2_correctly()
|
||||
{
|
||||
var v1Header = BuildV1Header("TCP4", "192.168.1.1", "10.0.0.1", 12345, 443);
|
||||
var r1 = ProxyProtocolParser.Parse(v1Header);
|
||||
r1.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
r1.Address!.SrcIp.ToString().ShouldBe("192.168.1.1");
|
||||
|
||||
var v2Header = BuildV2Header("192.168.1.2", "10.0.0.1", 54321, 443);
|
||||
var r2 = ProxyProtocolParser.Parse(v2Header);
|
||||
r2.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
r2.Address!.SrcIp.ToString().ShouldBe("192.168.1.2");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A header that starts with neither "PROXY " nor the v2 binary signature must
|
||||
/// throw a <see cref="ProxyProtocolException"/> indicating the format is unrecognized.
|
||||
/// Ref: TestClientProxyProtoUnrecognizedVersion (client_proxyproto_test.go:587)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Unrecognized_header_throws()
|
||||
{
|
||||
var header = Encoding.ASCII.GetBytes("HELLO WORLD\r\n");
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(header));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A data buffer shorter than 6 bytes cannot carry any valid PROXY header prefix
|
||||
/// and must throw.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Too_short_input_throws()
|
||||
{
|
||||
Should.Throw<ProxyProtocolException>(() => ProxyProtocolParser.Parse(new byte[] { 0x50, 0x52 }));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Additional edge cases (not directly from Go tests but needed for full coverage)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// ParseV1 operating directly on the bytes after the "PROXY " prefix correctly
|
||||
/// extracts a TCP4 address without going through the auto-detector.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseV1_direct_entry_point_works()
|
||||
{
|
||||
var afterPrefix = Encoding.ASCII.GetBytes("TCP4 1.2.3.4 5.6.7.8 1234 4222\r\n");
|
||||
var result = ProxyProtocolParser.ParseV1(afterPrefix);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address!.SrcIp.ToString().ShouldBe("1.2.3.4");
|
||||
result.Address.SrcPort.ShouldBe((ushort)1234);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ParseV2AfterSig operating on the 4-byte post-signature header correctly parses
|
||||
/// a PROXY command with the full IPv4 address block appended.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParseV2AfterSig_direct_entry_point_works()
|
||||
{
|
||||
// Build just the 4 header bytes + 12 address bytes (no sig)
|
||||
var ms = new MemoryStream();
|
||||
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
|
||||
ms.WriteByte(0x10 | 0x01); // family=IPv4, proto=STREAM
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x0C); // addr-len = 12
|
||||
// src IP 192.168.0.1, dst IP 10.0.0.1, src port 9999, dst port 4222
|
||||
ms.Write(IPAddress.Parse("192.168.0.1").GetAddressBytes());
|
||||
ms.Write(IPAddress.Parse("10.0.0.1").GetAddressBytes());
|
||||
var ports = new byte[4];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(ports.AsSpan(0), 9999);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(ports.AsSpan(2), 4222);
|
||||
ms.Write(ports);
|
||||
|
||||
var result = ProxyProtocolParser.ParseV2AfterSig(ms.ToArray());
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address!.SrcIp.ToString().ShouldBe("192.168.0.1");
|
||||
result.Address.SrcPort.ShouldBe((ushort)9999);
|
||||
result.Address.DstPort.ShouldBe((ushort)4222);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A v2 UNSPEC family with PROXY command returns a Local result (no address override).
|
||||
/// The Go implementation discards unspec address data and returns nil addr.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void V2_UNSPEC_family_returns_local()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes("\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"));
|
||||
ms.WriteByte(0x20 | 0x01); // ver=2, cmd=PROXY
|
||||
ms.WriteByte(0x00 | 0x01); // family=UNSPEC, proto=STREAM
|
||||
ms.WriteByte(0x00);
|
||||
ms.WriteByte(0x00); // addr-len = 0
|
||||
|
||||
var result = ProxyProtocolParser.ParseV2(ms.ToArray());
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Local);
|
||||
result.Address.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BuildV2Header round-trips — parsing the output of the builder yields the same
|
||||
/// addresses that were passed in, for both IPv4 and IPv6.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildV2Header_round_trips_IPv4()
|
||||
{
|
||||
var bytes = BuildV2Header("203.0.113.50", "127.0.0.1", 54321, 4222);
|
||||
var result = ProxyProtocolParser.Parse(bytes);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address!.SrcIp.ToString().ShouldBe("203.0.113.50");
|
||||
result.Address.SrcPort.ShouldBe((ushort)54321);
|
||||
result.Address.DstIp.ToString().ShouldBe("127.0.0.1");
|
||||
result.Address.DstPort.ShouldBe((ushort)4222);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildV2Header_round_trips_IPv6()
|
||||
{
|
||||
var bytes = BuildV2Header("fe80::1", "fe80::2", 1234, 4222, ipv6: true);
|
||||
var result = ProxyProtocolParser.Parse(bytes);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address!.Network.ShouldBe("tcp6");
|
||||
result.Address.SrcPort.ShouldBe((ushort)1234);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BuildV1Header round-trips for both TCP4 and TCP6 lines.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildV1Header_round_trips_TCP4()
|
||||
{
|
||||
var bytes = BuildV1Header("TCP4", "203.0.113.50", "127.0.0.1", 54321, 4222);
|
||||
var result = ProxyProtocolParser.Parse(bytes);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address!.SrcIp.ToString().ShouldBe("203.0.113.50");
|
||||
result.Address.SrcPort.ShouldBe((ushort)54321);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildV1Header_round_trips_TCP6()
|
||||
{
|
||||
var bytes = BuildV1Header("TCP6", "2001:db8::cafe", "2001:db8::1", 11111, 4222);
|
||||
var result = ProxyProtocolParser.Parse(bytes);
|
||||
|
||||
result.Kind.ShouldBe(ProxyParseResultKind.Proxy);
|
||||
result.Address!.SrcIp.ToString().ShouldBe("2001:db8::cafe");
|
||||
result.Address.SrcPort.ShouldBe((ushort)11111);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user