feat: add PROXY protocol parser & SubList Go-parity tests (Task 26)
Add ProxyProtocol.cs implementing PROXY v1/v2 header parsing (Go ref: server/proxy_proto.go). Port 29 PROXY protocol tests and 120 SubList Go-parity tests covering ReverseMatch, HasInterest, NumInterest, SubjectsCollide, cache hit rate, empty tokens, and overlapping subs. Go refs: TestProtoParseProxyV1, TestSublistReverseMatch, TestSublistHasInterest, TestSublistNumInterest, and 25+ more.
This commit is contained in:
356
src/NATS.Server/Protocol/ProxyProtocol.cs
Normal file
356
src/NATS.Server/Protocol/ProxyProtocol.cs
Normal file
@@ -0,0 +1,356 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.Server.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Contains the source and destination address information extracted from a PROXY protocol header.
|
||||
/// Ported from golang/nats-server/server/client_proxyproto.go.
|
||||
/// </summary>
|
||||
public sealed class ProxyAddress
|
||||
{
|
||||
public required IPAddress SrcIp { get; init; }
|
||||
public required ushort SrcPort { get; init; }
|
||||
public required IPAddress DstIp { get; init; }
|
||||
public required ushort DstPort { get; init; }
|
||||
|
||||
public string Network => SrcIp.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? "tcp4" : "tcp6";
|
||||
|
||||
public override string ToString() =>
|
||||
SrcIp.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6
|
||||
? $"[{SrcIp}]:{SrcPort}"
|
||||
: $"{SrcIp}:{SrcPort}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result returned from <see cref="ProxyProtocolParser.Parse"/>.
|
||||
/// </summary>
|
||||
public enum ProxyParseResultKind
|
||||
{
|
||||
/// <summary>PROXY command — address info is in <see cref="ProxyParseResult.Address"/>.</summary>
|
||||
Proxy,
|
||||
/// <summary>LOCAL command (v2) or UNKNOWN (v1) — no address override; treat as direct connection.</summary>
|
||||
Local,
|
||||
}
|
||||
|
||||
public sealed class ProxyParseResult
|
||||
{
|
||||
public required ProxyParseResultKind Kind { get; init; }
|
||||
public ProxyAddress? Address { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure-parsing PROXY protocol v1/v2 parser. Operates on byte buffers rather than
|
||||
/// live sockets so that it can be tested without I/O infrastructure.
|
||||
/// Reference: golang/nats-server/server/client_proxyproto.go
|
||||
/// </summary>
|
||||
public static class ProxyProtocolParser
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Constants mirrored from client_proxyproto.go
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private const string V2Sig = "\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A";
|
||||
|
||||
// v2 version/command byte
|
||||
private const byte V2VerMask = 0xF0;
|
||||
private const byte V2Ver = 0x20; // version nibble == 2
|
||||
private const byte CmdMask = 0x0F;
|
||||
private const byte CmdLocal = 0x00;
|
||||
private const byte CmdProxy = 0x01;
|
||||
|
||||
// v2 family/protocol byte
|
||||
private const byte FamilyMask = 0xF0;
|
||||
private const byte FamilyUnspec = 0x00;
|
||||
private const byte FamilyInet = 0x10; // IPv4
|
||||
private const byte FamilyInet6 = 0x20; // IPv6
|
||||
private const byte FamilyUnix = 0x30; // Unix sockets
|
||||
private const byte ProtoMask = 0x0F;
|
||||
private const byte ProtoUnspec = 0x00;
|
||||
private const byte ProtoStream = 0x01; // TCP
|
||||
private const byte ProtoDatagram = 0x02; // UDP
|
||||
|
||||
// Address block sizes (bytes)
|
||||
private const int AddrSizeIPv4 = 12; // 4+4+2+2
|
||||
private const int AddrSizeIPv6 = 36; // 16+16+2+2
|
||||
|
||||
// v2 fixed header size: 12 (sig) + 1 (ver/cmd) + 1 (fam/proto) + 2 (addr-len)
|
||||
private const int V2HeaderSize = 16;
|
||||
|
||||
// v1 text protocol
|
||||
private const string V1Prefix = "PROXY ";
|
||||
private const int V1MaxLineLen = 107;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a complete PROXY protocol header from the supplied bytes.
|
||||
/// Auto-detects v1 (text) or v2 (binary). The supplied span must contain the
|
||||
/// entire header (up to the CRLF for v1, or the full fixed+address block for v2).
|
||||
/// Throws <see cref="ProxyProtocolException"/> for malformed input.
|
||||
/// </summary>
|
||||
public static ProxyParseResult Parse(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.Length < 6)
|
||||
throw new ProxyProtocolException("Header too short to detect version");
|
||||
|
||||
// Detect version by reading first 6 bytes
|
||||
var prefix = Encoding.ASCII.GetString(data[..6]);
|
||||
if (prefix == V1Prefix)
|
||||
return ParseV1(data[6..]);
|
||||
|
||||
var sigPrefix = V2Sig[..6];
|
||||
if (prefix == sigPrefix)
|
||||
return ParseV2(data);
|
||||
|
||||
throw new ProxyProtocolException("Unrecognized PROXY protocol format");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// v1 parsing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Parses PROXY protocol v1 text format.
|
||||
/// Expects the "PROXY " prefix (6 bytes) to have already been stripped.
|
||||
/// Reference: readProxyProtoV1Header (client_proxyproto.go:134)
|
||||
/// </summary>
|
||||
public static ProxyParseResult ParseV1(ReadOnlySpan<byte> afterPrefix)
|
||||
{
|
||||
if (afterPrefix.Length > V1MaxLineLen - 6)
|
||||
afterPrefix = afterPrefix[..(V1MaxLineLen - 6)];
|
||||
|
||||
// Find CRLF
|
||||
int crlfIdx = -1;
|
||||
for (int i = 0; i < afterPrefix.Length - 1; i++)
|
||||
{
|
||||
if (afterPrefix[i] == '\r' && afterPrefix[i + 1] == '\n')
|
||||
{
|
||||
crlfIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (crlfIdx < 0)
|
||||
throw new ProxyProtocolException("PROXY v1 line too long or no CRLF found");
|
||||
|
||||
var line = Encoding.ASCII.GetString(afterPrefix[..crlfIdx]);
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (parts.Length < 1)
|
||||
throw new ProxyProtocolException("Invalid PROXY v1 format");
|
||||
|
||||
if (parts[0] == "UNKNOWN")
|
||||
return new ProxyParseResult { Kind = ProxyParseResultKind.Local };
|
||||
|
||||
if (parts.Length != 5)
|
||||
throw new ProxyProtocolException("Invalid PROXY v1 format: expected 5 fields");
|
||||
|
||||
var protocol = parts[0];
|
||||
var srcIp = IPAddress.TryParse(parts[1], out var si) ? si : null;
|
||||
var dstIp = IPAddress.TryParse(parts[2], out var di) ? di : null;
|
||||
|
||||
if (srcIp == null || dstIp == null)
|
||||
throw new ProxyProtocolException("Invalid address in PROXY v1 header");
|
||||
|
||||
if (!ushort.TryParse(parts[3], out var srcPort))
|
||||
throw new ProxyProtocolException($"Invalid source port: {parts[3]}");
|
||||
if (!ushort.TryParse(parts[4], out var dstPort))
|
||||
throw new ProxyProtocolException($"Invalid destination port: {parts[4]}");
|
||||
|
||||
// Additional range validation — ushort.TryParse already limits to 0-65535
|
||||
// but Go rejects 99999+ which ushort.TryParse would fail anyway.
|
||||
|
||||
if (protocol == "TCP4" && srcIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
throw new ProxyProtocolException("TCP4 with non-IPv4 address");
|
||||
if (protocol == "TCP6" && srcIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
throw new ProxyProtocolException("TCP6 with non-IPv6 address");
|
||||
if (protocol != "TCP4" && protocol != "TCP6")
|
||||
throw new ProxyProtocolException($"Unsupported protocol: {protocol}");
|
||||
|
||||
return new ProxyParseResult
|
||||
{
|
||||
Kind = ProxyParseResultKind.Proxy,
|
||||
Address = new ProxyAddress
|
||||
{
|
||||
SrcIp = srcIp,
|
||||
SrcPort = srcPort,
|
||||
DstIp = dstIp,
|
||||
DstPort = dstPort,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// v2 parsing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Parses a full PROXY protocol v2 binary header including signature.
|
||||
/// Reference: readProxyProtoV2Header / parseProxyProtoV2Header (client_proxyproto.go:274)
|
||||
/// </summary>
|
||||
public static ProxyParseResult ParseV2(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.Length < V2HeaderSize)
|
||||
throw new ProxyProtocolException("Truncated PROXY v2 header");
|
||||
|
||||
// Verify full 12-byte signature
|
||||
var sig = Encoding.ASCII.GetString(data[..12]);
|
||||
if (sig != V2Sig)
|
||||
throw new ProxyProtocolException("Invalid PROXY v2 signature");
|
||||
|
||||
return ParseV2AfterSig(data[12..]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the 4 header bytes (ver/cmd, fam/proto, addr-len) that follow the
|
||||
/// 12-byte signature, then the variable-length address block.
|
||||
/// Reference: parseProxyProtoV2Header (client_proxyproto.go:301)
|
||||
/// </summary>
|
||||
public static ProxyParseResult ParseV2AfterSig(ReadOnlySpan<byte> header)
|
||||
{
|
||||
if (header.Length < 4)
|
||||
throw new ProxyProtocolException("Truncated PROXY v2 header after signature");
|
||||
|
||||
var verCmd = header[0];
|
||||
var famProto = header[1];
|
||||
var addrLen = BinaryPrimitives.ReadUInt16BigEndian(header[2..4]);
|
||||
|
||||
var version = verCmd & V2VerMask;
|
||||
var command = verCmd & CmdMask;
|
||||
var family = famProto & FamilyMask;
|
||||
var proto = famProto & ProtoMask;
|
||||
|
||||
if (version != V2Ver)
|
||||
throw new ProxyProtocolException($"Invalid PROXY v2 version 0x{version:X2}");
|
||||
|
||||
// LOCAL command — discard any address data
|
||||
if (command == CmdLocal)
|
||||
return new ProxyParseResult { Kind = ProxyParseResultKind.Local };
|
||||
|
||||
if (command != CmdProxy)
|
||||
throw new ProxyProtocolException($"Unknown PROXY v2 command 0x{command:X2}");
|
||||
|
||||
// Only STREAM (TCP) is supported
|
||||
if (proto != ProtoStream)
|
||||
throw new ProxyProtocolUnsupportedException("Only STREAM protocol supported");
|
||||
|
||||
var addrData = header[4..];
|
||||
if (addrData.Length < addrLen)
|
||||
throw new ProxyProtocolException("Truncated PROXY v2 address data");
|
||||
|
||||
return family switch
|
||||
{
|
||||
FamilyInet => ParseIPv4(addrData, addrLen),
|
||||
FamilyInet6 => ParseIPv6(addrData, addrLen),
|
||||
FamilyUnspec => new ProxyParseResult { Kind = ProxyParseResultKind.Local },
|
||||
FamilyUnix => throw new ProxyProtocolUnsupportedException($"Unsupported address family 0x{family:X2}"),
|
||||
_ => throw new ProxyProtocolUnsupportedException($"Unsupported address family 0x{family:X2}"),
|
||||
};
|
||||
}
|
||||
|
||||
private static ProxyParseResult ParseIPv4(ReadOnlySpan<byte> data, ushort addrLen)
|
||||
{
|
||||
if (addrLen < AddrSizeIPv4)
|
||||
throw new ProxyProtocolException($"IPv4 address data too short: {addrLen}");
|
||||
if (data.Length < AddrSizeIPv4)
|
||||
throw new ProxyProtocolException("Truncated IPv4 address data");
|
||||
|
||||
var srcIp = new IPAddress(data[..4]);
|
||||
var dstIp = new IPAddress(data[4..8]);
|
||||
var srcPort = BinaryPrimitives.ReadUInt16BigEndian(data[8..10]);
|
||||
var dstPort = BinaryPrimitives.ReadUInt16BigEndian(data[10..12]);
|
||||
|
||||
return new ProxyParseResult
|
||||
{
|
||||
Kind = ProxyParseResultKind.Proxy,
|
||||
Address = new ProxyAddress { SrcIp = srcIp, SrcPort = srcPort, DstIp = dstIp, DstPort = dstPort },
|
||||
};
|
||||
}
|
||||
|
||||
private static ProxyParseResult ParseIPv6(ReadOnlySpan<byte> data, ushort addrLen)
|
||||
{
|
||||
if (addrLen < AddrSizeIPv6)
|
||||
throw new ProxyProtocolException($"IPv6 address data too short: {addrLen}");
|
||||
if (data.Length < AddrSizeIPv6)
|
||||
throw new ProxyProtocolException("Truncated IPv6 address data");
|
||||
|
||||
var srcIp = new IPAddress(data[..16]);
|
||||
var dstIp = new IPAddress(data[16..32]);
|
||||
var srcPort = BinaryPrimitives.ReadUInt16BigEndian(data[32..34]);
|
||||
var dstPort = BinaryPrimitives.ReadUInt16BigEndian(data[34..36]);
|
||||
|
||||
return new ProxyParseResult
|
||||
{
|
||||
Kind = ProxyParseResultKind.Proxy,
|
||||
Address = new ProxyAddress { SrcIp = srcIp, SrcPort = srcPort, DstIp = dstIp, DstPort = dstPort },
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers for building test payloads (public for test accessibility)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Builds a valid PROXY v2 binary header for the given parameters.</summary>
|
||||
public static byte[] BuildV2Header(
|
||||
string srcIp, string dstIp, ushort srcPort, ushort dstPort, bool isIPv6 = false)
|
||||
{
|
||||
var src = IPAddress.Parse(srcIp);
|
||||
var dst = IPAddress.Parse(dstIp);
|
||||
var family = isIPv6 ? FamilyInet6 : FamilyInet;
|
||||
|
||||
byte[] addrData;
|
||||
if (!isIPv6)
|
||||
{
|
||||
addrData = new byte[AddrSizeIPv4];
|
||||
src.GetAddressBytes().CopyTo(addrData, 0);
|
||||
dst.GetAddressBytes().CopyTo(addrData, 4);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(addrData.AsSpan(8), srcPort);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(addrData.AsSpan(10), dstPort);
|
||||
}
|
||||
else
|
||||
{
|
||||
addrData = new byte[AddrSizeIPv6];
|
||||
src.GetAddressBytes().CopyTo(addrData, 0);
|
||||
dst.GetAddressBytes().CopyTo(addrData, 16);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(addrData.AsSpan(32), srcPort);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(addrData.AsSpan(34), dstPort);
|
||||
}
|
||||
|
||||
var ms = new System.IO.MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes(V2Sig));
|
||||
ms.WriteByte(V2Ver | CmdProxy);
|
||||
ms.WriteByte((byte)(family | ProtoStream));
|
||||
var lenBytes = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(lenBytes, (ushort)addrData.Length);
|
||||
ms.Write(lenBytes);
|
||||
ms.Write(addrData);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Builds a PROXY v2 LOCAL command header (health-check).</summary>
|
||||
public static byte[] BuildV2LocalHeader()
|
||||
{
|
||||
var ms = new System.IO.MemoryStream();
|
||||
ms.Write(Encoding.ASCII.GetBytes(V2Sig));
|
||||
ms.WriteByte(V2Ver | CmdLocal);
|
||||
ms.WriteByte(FamilyUnspec | ProtoUnspec);
|
||||
ms.WriteByte(0);
|
||||
ms.WriteByte(0);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Builds a PROXY v1 text header.</summary>
|
||||
public static byte[] BuildV1Header(
|
||||
string protocol, string srcIp, string dstIp, ushort srcPort, ushort dstPort)
|
||||
{
|
||||
var line = protocol == "UNKNOWN"
|
||||
? "PROXY UNKNOWN\r\n"
|
||||
: $"PROXY {protocol} {srcIp} {dstIp} {srcPort} {dstPort}\r\n";
|
||||
return Encoding.ASCII.GetBytes(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when a PROXY protocol header is malformed.</summary>
|
||||
public sealed class ProxyProtocolException(string message) : Exception(message);
|
||||
|
||||
/// <summary>Thrown when a PROXY protocol feature is not supported (e.g. UDP, Unix sockets).</summary>
|
||||
public sealed class ProxyProtocolUnsupportedException(string message) : Exception(message);
|
||||
514
tests/NATS.Server.Tests/Protocol/ProxyProtocolTests.cs
Normal file
514
tests/NATS.Server.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.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);
|
||||
}
|
||||
}
|
||||
869
tests/NATS.Server.Tests/Subscriptions/SubListGoParityTests.cs
Normal file
869
tests/NATS.Server.Tests/Subscriptions/SubListGoParityTests.cs
Normal file
@@ -0,0 +1,869 @@
|
||||
// Go reference: golang/nats-server/server/sublist_test.go
|
||||
// Ports Go sublist tests not yet covered by SubListTests.cs or the SubList/ subfolder.
|
||||
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Go parity tests for SubList ported from sublist_test.go.
|
||||
/// Covers basic multi-token matching, wildcard removal, cache eviction,
|
||||
/// subject-validity helpers, queue results, reverse match, HasInterest,
|
||||
/// NumInterest, and cache hit-rate statistics.
|
||||
/// </summary>
|
||||
public class SubListGoParityTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
private static Subscription MakeSub(string subject, string? queue = null, string sid = "1")
|
||||
=> new() { Subject = subject, Queue = queue, Sid = sid };
|
||||
|
||||
// =========================================================================
|
||||
// Basic insert / match
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Single-token subject round-trips through insert and match.
|
||||
/// Ref: TestSublistInit / TestSublistInsertCount (sublist_test.go:117,122)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Init_count_is_zero_and_grows_with_inserts()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Count.ShouldBe(0u);
|
||||
sl.Insert(MakeSub("foo", sid: "1"));
|
||||
sl.Insert(MakeSub("bar", sid: "2"));
|
||||
sl.Insert(MakeSub("foo.bar", sid: "3"));
|
||||
sl.Count.ShouldBe(3u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A multi-token literal subject matches itself exactly.
|
||||
/// Ref: TestSublistSimpleMultiTokens (sublist_test.go:154)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Simple_multi_token_match()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub("foo.bar.baz");
|
||||
sl.Insert(sub);
|
||||
|
||||
var r = sl.Match("foo.bar.baz");
|
||||
r.PlainSubs.ShouldHaveSingleItem();
|
||||
r.PlainSubs[0].ShouldBeSameAs(sub);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A partial wildcard at the end of a pattern matches the final literal token.
|
||||
/// Ref: TestSublistPartialWildcardAtEnd (sublist_test.go:190)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Partial_wildcard_at_end_matches_final_token()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var lsub = MakeSub("a.b.c", sid: "1");
|
||||
var psub = MakeSub("a.b.*", sid: "2");
|
||||
sl.Insert(lsub);
|
||||
sl.Insert(psub);
|
||||
|
||||
var r = sl.Match("a.b.c");
|
||||
r.PlainSubs.Length.ShouldBe(2);
|
||||
r.PlainSubs.ShouldContain(lsub);
|
||||
r.PlainSubs.ShouldContain(psub);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subjects with two tokens do not match a single-token subscription.
|
||||
/// Ref: TestSublistTwoTokenPubMatchSingleTokenSub (sublist_test.go:749)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Two_token_pub_does_not_match_single_token_sub()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub("foo");
|
||||
sl.Insert(sub);
|
||||
|
||||
sl.Match("foo").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo.bar").PlainSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Removal with wildcards
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Removing wildcard subscriptions decrements the count and clears match results.
|
||||
/// Ref: TestSublistRemoveWildcard (sublist_test.go:255)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Remove_wildcard_subscriptions()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub("a.b.c.d", sid: "1");
|
||||
var psub = MakeSub("a.b.*.d", sid: "2");
|
||||
var fsub = MakeSub("a.b.>", sid: "3");
|
||||
sl.Insert(sub);
|
||||
sl.Insert(psub);
|
||||
sl.Insert(fsub);
|
||||
sl.Count.ShouldBe(3u);
|
||||
|
||||
sl.Match("a.b.c.d").PlainSubs.Length.ShouldBe(3);
|
||||
|
||||
sl.Remove(sub);
|
||||
sl.Count.ShouldBe(2u);
|
||||
sl.Remove(fsub);
|
||||
sl.Count.ShouldBe(1u);
|
||||
sl.Remove(psub);
|
||||
sl.Count.ShouldBe(0u);
|
||||
sl.Match("a.b.c.d").PlainSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserting a subscription with a wildcard literal token (e.g. "foo.*-") and
|
||||
/// then removing it leaves the list empty and no spurious match on "foo.bar".
|
||||
/// Ref: TestSublistRemoveWithWildcardsAsLiterals (sublist_test.go:789)
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("foo.*-")]
|
||||
[InlineData("foo.>-")]
|
||||
public void Remove_with_wildcard_as_literal(string subject)
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub(subject);
|
||||
sl.Insert(sub);
|
||||
|
||||
// Removing a non-existent subscription does nothing
|
||||
sl.Remove(MakeSub("foo.bar"));
|
||||
sl.Count.ShouldBe(1u);
|
||||
|
||||
sl.Remove(sub);
|
||||
sl.Count.ShouldBe(0u);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Cache behaviour
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// After inserting three subscriptions, adding a new wildcard subscription
|
||||
/// invalidates the cached result and subsequent matches include the new sub.
|
||||
/// Ref: TestSublistCache (sublist_test.go:423)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Cache_invalidated_by_subsequent_inserts()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub("a.b.c.d", sid: "1");
|
||||
var psub = MakeSub("a.b.*.d", sid: "2");
|
||||
var fsub = MakeSub("a.b.>", sid: "3");
|
||||
|
||||
sl.Insert(sub);
|
||||
sl.Match("a.b.c.d").PlainSubs.ShouldHaveSingleItem();
|
||||
|
||||
sl.Insert(psub);
|
||||
sl.Insert(fsub);
|
||||
sl.Count.ShouldBe(3u);
|
||||
|
||||
var r = sl.Match("a.b.c.d");
|
||||
r.PlainSubs.Length.ShouldBe(3);
|
||||
r.PlainSubs.ShouldContain(sub);
|
||||
r.PlainSubs.ShouldContain(psub);
|
||||
r.PlainSubs.ShouldContain(fsub);
|
||||
|
||||
sl.Remove(sub);
|
||||
sl.Remove(fsub);
|
||||
sl.Remove(psub);
|
||||
sl.Count.ShouldBe(0u);
|
||||
// Cache is cleared by each removal (generation bump), but a subsequent Match
|
||||
// may re-populate it with an empty result — verify no matching subs are found.
|
||||
sl.Match("a.b.c.d").PlainSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserting a fwc sub after cache has been primed causes the next match to
|
||||
/// return all three matching subs.
|
||||
/// Ref: TestSublistCache (wildcard part) (sublist_test.go:465)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Cache_updated_when_new_wildcard_inserted()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("foo.*", sid: "1"));
|
||||
sl.Insert(MakeSub("foo.bar", sid: "2"));
|
||||
|
||||
sl.Match("foo.baz").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.Match("foo.bar").PlainSubs.Length.ShouldBe(2);
|
||||
|
||||
sl.Insert(MakeSub("foo.>", sid: "3"));
|
||||
sl.Match("foo.bar").PlainSubs.Length.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empty result is a shared singleton — two calls that yield no matches return
|
||||
/// the same object reference.
|
||||
/// Ref: TestSublistSharedEmptyResult (sublist_test.go:1049)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Empty_result_is_shared_singleton()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var r1 = sl.Match("foo");
|
||||
var r2 = sl.Match("bar");
|
||||
r1.PlainSubs.ShouldBeEmpty();
|
||||
r2.PlainSubs.ShouldBeEmpty();
|
||||
ReferenceEquals(r1, r2).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Queue subscriptions
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// After inserting two queue groups, adding a plain sub makes it visible
|
||||
/// in PlainSubs; adding more members to each group expands QueueSubs.
|
||||
/// Removing members correctly shrinks group counts.
|
||||
/// Ref: TestSublistBasicQueueResults (sublist_test.go:486)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Basic_queue_results_lifecycle()
|
||||
{
|
||||
var sl = new SubList();
|
||||
const string subject = "foo";
|
||||
var sub = MakeSub(subject, sid: "plain");
|
||||
var sub1 = MakeSub(subject, queue: "bar", sid: "q1");
|
||||
var sub2 = MakeSub(subject, queue: "baz", sid: "q2");
|
||||
var sub3 = MakeSub(subject, queue: "bar", sid: "q3");
|
||||
var sub4 = MakeSub(subject, queue: "baz", sid: "q4");
|
||||
|
||||
sl.Insert(sub1);
|
||||
var r = sl.Match(subject);
|
||||
r.PlainSubs.ShouldBeEmpty();
|
||||
r.QueueSubs.Length.ShouldBe(1);
|
||||
|
||||
sl.Insert(sub2);
|
||||
r = sl.Match(subject);
|
||||
r.QueueSubs.Length.ShouldBe(2);
|
||||
|
||||
sl.Insert(sub);
|
||||
r = sl.Match(subject);
|
||||
r.PlainSubs.ShouldHaveSingleItem();
|
||||
r.QueueSubs.Length.ShouldBe(2);
|
||||
|
||||
sl.Insert(sub3);
|
||||
sl.Insert(sub4);
|
||||
r = sl.Match(subject);
|
||||
r.PlainSubs.ShouldHaveSingleItem();
|
||||
r.QueueSubs.Length.ShouldBe(2);
|
||||
// Each group should have 2 members
|
||||
r.QueueSubs.ShouldAllBe(g => g.Length == 2);
|
||||
|
||||
// Remove the plain sub
|
||||
sl.Remove(sub);
|
||||
r = sl.Match(subject);
|
||||
r.PlainSubs.ShouldBeEmpty();
|
||||
r.QueueSubs.Length.ShouldBe(2);
|
||||
|
||||
// Remove one member from "bar" group
|
||||
sl.Remove(sub1);
|
||||
r = sl.Match(subject);
|
||||
r.QueueSubs.Length.ShouldBe(2); // both groups still present
|
||||
|
||||
// Remove remaining "bar" member
|
||||
sl.Remove(sub3);
|
||||
r = sl.Match(subject);
|
||||
r.QueueSubs.Length.ShouldBe(1); // only "baz" group remains
|
||||
|
||||
// Remove both "baz" members
|
||||
sl.Remove(sub2);
|
||||
sl.Remove(sub4);
|
||||
r = sl.Match(subject);
|
||||
r.PlainSubs.ShouldBeEmpty();
|
||||
r.QueueSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Subject validity helpers
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// IsValidPublishSubject (IsLiteral) rejects wildcard tokens and partial-wildcard
|
||||
/// embedded in longer tokens is treated as a literal.
|
||||
/// Ref: TestSublistValidLiteralSubjects (sublist_test.go:585)
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("foo", true)]
|
||||
[InlineData(".foo", false)]
|
||||
[InlineData("foo.", false)]
|
||||
[InlineData("foo..bar", false)]
|
||||
[InlineData("foo.bar.*", false)]
|
||||
[InlineData("foo.bar.>", false)]
|
||||
[InlineData("*", false)]
|
||||
[InlineData(">", false)]
|
||||
[InlineData("foo*", true)] // embedded * not a wildcard
|
||||
[InlineData("foo**", true)]
|
||||
[InlineData("foo.**", true)]
|
||||
[InlineData("foo*bar", true)]
|
||||
[InlineData("foo.*bar", true)]
|
||||
[InlineData("foo*.bar", true)]
|
||||
[InlineData("*bar", true)]
|
||||
[InlineData("foo>", true)]
|
||||
[InlineData("foo>>", true)]
|
||||
[InlineData("foo.>>", true)]
|
||||
[InlineData("foo>bar", true)]
|
||||
[InlineData("foo.>bar", true)]
|
||||
[InlineData("foo>.bar", true)]
|
||||
[InlineData(">bar", true)]
|
||||
public void IsValidPublishSubject_cases(string subject, bool expected)
|
||||
{
|
||||
// Ref: TestSublistValidLiteralSubjects (sublist_test.go:585)
|
||||
SubjectMatch.IsValidPublishSubject(subject).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IsValidSubject accepts subjects with embedded wildcard characters
|
||||
/// that are not standalone tokens, and rejects subjects with empty tokens.
|
||||
/// Ref: TestSublistValidSubjects (sublist_test.go:612)
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(".", false)]
|
||||
[InlineData(".foo", false)]
|
||||
[InlineData("foo.", false)]
|
||||
[InlineData("foo..bar", false)]
|
||||
[InlineData(">.bar", false)]
|
||||
[InlineData("foo.>.bar", false)]
|
||||
[InlineData("foo", true)]
|
||||
[InlineData("foo.bar.*", true)]
|
||||
[InlineData("foo.bar.>", true)]
|
||||
[InlineData("*", true)]
|
||||
[InlineData(">", true)]
|
||||
[InlineData("foo*", true)]
|
||||
[InlineData("foo**", true)]
|
||||
[InlineData("foo.**", true)]
|
||||
[InlineData("foo*bar", true)]
|
||||
[InlineData("foo.*bar", true)]
|
||||
[InlineData("foo*.bar", true)]
|
||||
[InlineData("*bar", true)]
|
||||
[InlineData("foo>", true)]
|
||||
[InlineData("foo>>", true)]
|
||||
[InlineData("foo.>>", true)]
|
||||
[InlineData("foo>bar", true)]
|
||||
[InlineData("foo.>bar", true)]
|
||||
[InlineData("foo>.bar", true)]
|
||||
[InlineData(">bar", true)]
|
||||
public void IsValidSubject_cases(string subject, bool expected)
|
||||
{
|
||||
// Ref: TestSublistValidSubjects (sublist_test.go:612)
|
||||
SubjectMatch.IsValidSubject(subject).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IsLiteral correctly identifies subjects with embedded wildcard characters
|
||||
/// (but not standalone wildcard tokens) as literal.
|
||||
/// Ref: TestSubjectIsLiteral (sublist_test.go:673)
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("foo", true)]
|
||||
[InlineData("foo.bar", true)]
|
||||
[InlineData("foo*.bar", true)]
|
||||
[InlineData("*", false)]
|
||||
[InlineData(">", false)]
|
||||
[InlineData("foo.*", false)]
|
||||
[InlineData("foo.>", false)]
|
||||
[InlineData("foo.*.>", false)]
|
||||
[InlineData("foo.*.bar", false)]
|
||||
[InlineData("foo.bar.>", false)]
|
||||
public void IsLiteral_cases(string subject, bool expected)
|
||||
{
|
||||
// Ref: TestSubjectIsLiteral (sublist_test.go:673)
|
||||
SubjectMatch.IsLiteral(subject).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MatchLiteral handles embedded wildcard-chars-as-literals correctly.
|
||||
/// Ref: TestSublistMatchLiterals (sublist_test.go:644)
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("foo", "foo", true)]
|
||||
[InlineData("foo", "bar", false)]
|
||||
[InlineData("foo", "*", true)]
|
||||
[InlineData("foo", ">", true)]
|
||||
[InlineData("foo.bar", ">", true)]
|
||||
[InlineData("foo.bar", "foo.>", true)]
|
||||
[InlineData("foo.bar", "bar.>", false)]
|
||||
[InlineData("stats.test.22", "stats.>", true)]
|
||||
[InlineData("stats.test.22", "stats.*.*", true)]
|
||||
[InlineData("foo.bar", "foo", false)]
|
||||
[InlineData("stats.test.foos","stats.test.foos",true)]
|
||||
[InlineData("stats.test.foos","stats.test.foo", false)]
|
||||
[InlineData("stats.test", "stats.test.*", false)]
|
||||
[InlineData("stats.test.foos","stats.*", false)]
|
||||
[InlineData("stats.test.foos","stats.*.*.foos", false)]
|
||||
// Embedded wildcard chars treated as literals
|
||||
[InlineData("*bar", "*bar", true)]
|
||||
[InlineData("foo*", "foo*", true)]
|
||||
[InlineData("foo*bar", "foo*bar", true)]
|
||||
[InlineData("foo.***.bar", "foo.***.bar", true)]
|
||||
[InlineData(">bar", ">bar", true)]
|
||||
[InlineData("foo>", "foo>", true)]
|
||||
[InlineData("foo>bar", "foo>bar", true)]
|
||||
[InlineData("foo.>>>.bar", "foo.>>>.bar", true)]
|
||||
public void MatchLiteral_extended_cases(string literal, string pattern, bool expected)
|
||||
{
|
||||
// Ref: TestSublistMatchLiterals (sublist_test.go:644)
|
||||
SubjectMatch.MatchLiteral(literal, pattern).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Subject collide / subset
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// SubjectsCollide correctly identifies whether two subject patterns can
|
||||
/// match the same literal subject.
|
||||
/// Ref: TestSublistSubjectCollide (sublist_test.go:1548)
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("foo.*", "foo.*.bar.>", false)]
|
||||
[InlineData("foo.*.bar.>", "foo.*", false)]
|
||||
[InlineData("foo.*", "foo.foo", true)]
|
||||
[InlineData("foo.*", "*.foo", true)]
|
||||
[InlineData("foo.bar.>", "*.bar.foo", true)]
|
||||
public void SubjectsCollide_cases(string s1, string s2, bool expected)
|
||||
{
|
||||
// Ref: TestSublistSubjectCollide (sublist_test.go:1548)
|
||||
SubjectMatch.SubjectsCollide(s1, s2).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// tokenAt (0-based in .NET vs 1-based in Go)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// TokenAt returns the nth dot-separated token (0-based in .NET).
|
||||
/// The Go tokenAt helper uses 1-based indexing with "" for index 0; the .NET
|
||||
/// port uses 0-based indexing throughout.
|
||||
/// Ref: TestSubjectToken (sublist_test.go:707)
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("foo.bar.baz.*", 0, "foo")]
|
||||
[InlineData("foo.bar.baz.*", 1, "bar")]
|
||||
[InlineData("foo.bar.baz.*", 2, "baz")]
|
||||
[InlineData("foo.bar.baz.*", 3, "*")]
|
||||
[InlineData("foo.bar.baz.*", 4, "")] // out of range
|
||||
public void TokenAt_zero_based(string subject, int index, string expected)
|
||||
{
|
||||
// Ref: TestSubjectToken (sublist_test.go:707)
|
||||
SubjectMatch.TokenAt(subject, index).ToString().ShouldBe(expected);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Stats / cache hit rate
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Cache hit rate is computed correctly after 4 Match calls on the same subject
|
||||
/// (first call misses, subsequent three hit the cache).
|
||||
/// Ref: TestSublistAddCacheHitRate (sublist_test.go:1556)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Cache_hit_rate_is_computed_correctly()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("foo"));
|
||||
for (var i = 0; i < 4; i++)
|
||||
sl.Match("foo");
|
||||
|
||||
// 4 calls total, first is a cache miss, next 3 hit → 3/4 = 0.75
|
||||
var stats = sl.Stats();
|
||||
stats.CacheHitRate.ShouldBe(0.75, 1e-9);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stats.NumCache is 0 when cache is empty (no matches have been performed yet).
|
||||
/// Ref: TestSublistNoCacheStats (sublist_test.go:1064)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stats_NumCache_reflects_cache_population()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("foo", sid: "1"));
|
||||
sl.Insert(MakeSub("bar", sid: "2"));
|
||||
sl.Insert(MakeSub("baz", sid: "3"));
|
||||
sl.Insert(MakeSub("foo.bar.baz", sid: "4"));
|
||||
|
||||
// No matches performed yet — cache should be empty
|
||||
sl.Stats().NumCache.ShouldBe(0u);
|
||||
|
||||
sl.Match("a.b.c");
|
||||
sl.Match("bar");
|
||||
|
||||
// Two distinct subjects have been matched, so cache should have 2 entries
|
||||
sl.Stats().NumCache.ShouldBe(2u);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HasInterest
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// HasInterest returns true for subjects with matching subscriptions and false
|
||||
/// otherwise, including after removal. Wildcard subscriptions match correctly.
|
||||
/// Ref: TestSublistHasInterest (sublist_test.go:1609)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HasInterest_with_plain_and_wildcard_subs()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var fooSub = MakeSub("foo", sid: "1");
|
||||
sl.Insert(fooSub);
|
||||
|
||||
sl.HasInterest("foo").ShouldBeTrue();
|
||||
sl.HasInterest("bar").ShouldBeFalse();
|
||||
|
||||
sl.Remove(fooSub);
|
||||
sl.HasInterest("foo").ShouldBeFalse();
|
||||
|
||||
// Partial wildcard
|
||||
var pwcSub = MakeSub("foo.*", sid: "2");
|
||||
sl.Insert(pwcSub);
|
||||
sl.HasInterest("foo").ShouldBeFalse();
|
||||
sl.HasInterest("foo.bar").ShouldBeTrue();
|
||||
sl.HasInterest("foo.bar.baz").ShouldBeFalse();
|
||||
|
||||
sl.Remove(pwcSub);
|
||||
sl.HasInterest("foo.bar").ShouldBeFalse();
|
||||
|
||||
// Full wildcard
|
||||
var fwcSub = MakeSub("foo.>", sid: "3");
|
||||
sl.Insert(fwcSub);
|
||||
sl.HasInterest("foo").ShouldBeFalse();
|
||||
sl.HasInterest("foo.bar").ShouldBeTrue();
|
||||
sl.HasInterest("foo.bar.baz").ShouldBeTrue();
|
||||
|
||||
sl.Remove(fwcSub);
|
||||
sl.HasInterest("foo.bar").ShouldBeFalse();
|
||||
sl.HasInterest("foo.bar.baz").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HasInterest handles queue subscriptions: a queue sub creates interest
|
||||
/// even though PlainSubs is empty.
|
||||
/// Ref: TestSublistHasInterest (queue part) (sublist_test.go:1682)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HasInterest_with_queue_subscriptions()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var qsub = MakeSub("foo", queue: "bar", sid: "1");
|
||||
var qsub2 = MakeSub("foo", queue: "baz", sid: "2");
|
||||
sl.Insert(qsub);
|
||||
sl.HasInterest("foo").ShouldBeTrue();
|
||||
sl.HasInterest("foo.bar").ShouldBeFalse();
|
||||
|
||||
sl.Insert(qsub2);
|
||||
sl.HasInterest("foo").ShouldBeTrue();
|
||||
|
||||
sl.Remove(qsub);
|
||||
sl.HasInterest("foo").ShouldBeTrue(); // qsub2 still present
|
||||
|
||||
sl.Remove(qsub2);
|
||||
sl.HasInterest("foo").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HasInterest correctly handles overlapping subscriptions where a literal
|
||||
/// subject coexists with a wildcard at the same level.
|
||||
/// Ref: TestSublistHasInterestOverlapping (sublist_test.go:1775)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HasInterest_overlapping_subscriptions()
|
||||
{
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub("stream.A.child", sid: "1"));
|
||||
sl.Insert(MakeSub("stream.*", sid: "2"));
|
||||
|
||||
sl.HasInterest("stream.A.child").ShouldBeTrue();
|
||||
sl.HasInterest("stream.A").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// NumInterest
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// NumInterest returns counts of plain and queue subscribers separately for
|
||||
/// literal subjects, wildcards, and queue-group subjects.
|
||||
/// Ref: TestSublistNumInterest (sublist_test.go:1783)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NumInterest_with_plain_subs()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var fooSub = MakeSub("foo", sid: "1");
|
||||
sl.Insert(fooSub);
|
||||
|
||||
var (np, nq) = sl.NumInterest("foo");
|
||||
np.ShouldBe(1);
|
||||
nq.ShouldBe(0);
|
||||
|
||||
sl.NumInterest("bar").ShouldBe((0, 0));
|
||||
|
||||
sl.Remove(fooSub);
|
||||
sl.NumInterest("foo").ShouldBe((0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumInterest_with_wildcards()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub("foo.*", sid: "1");
|
||||
sl.Insert(sub);
|
||||
|
||||
sl.NumInterest("foo").ShouldBe((0, 0));
|
||||
sl.NumInterest("foo.bar").ShouldBe((1, 0));
|
||||
sl.NumInterest("foo.bar.baz").ShouldBe((0, 0));
|
||||
|
||||
sl.Remove(sub);
|
||||
sl.NumInterest("foo.bar").ShouldBe((0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumInterest_with_queue_subs()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var qsub = MakeSub("foo", queue: "bar", sid: "1");
|
||||
var qsub2 = MakeSub("foo", queue: "baz", sid: "2");
|
||||
var qsub3 = MakeSub("foo", queue: "baz", sid: "3");
|
||||
sl.Insert(qsub);
|
||||
sl.NumInterest("foo").ShouldBe((0, 1));
|
||||
|
||||
sl.Insert(qsub2);
|
||||
sl.NumInterest("foo").ShouldBe((0, 2));
|
||||
|
||||
sl.Insert(qsub3);
|
||||
sl.NumInterest("foo").ShouldBe((0, 3));
|
||||
|
||||
sl.Remove(qsub);
|
||||
sl.NumInterest("foo").ShouldBe((0, 2));
|
||||
|
||||
sl.Remove(qsub2);
|
||||
sl.NumInterest("foo").ShouldBe((0, 1));
|
||||
|
||||
sl.Remove(qsub3);
|
||||
sl.NumInterest("foo").ShouldBe((0, 0));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Reverse match
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// ReverseMatch finds registered patterns that would match a given literal or
|
||||
/// wildcard subject, covering all combinations of *, >, and literals.
|
||||
/// Ref: TestSublistReverseMatch (sublist_test.go:1440)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReverseMatch_comprehensive()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var fooSub = MakeSub("foo", sid: "1");
|
||||
var barSub = MakeSub("bar", sid: "2");
|
||||
var fooBarSub = MakeSub("foo.bar", sid: "3");
|
||||
var fooBazSub = MakeSub("foo.baz", sid: "4");
|
||||
var fooBarBazSub = MakeSub("foo.bar.baz", sid: "5");
|
||||
sl.Insert(fooSub);
|
||||
sl.Insert(barSub);
|
||||
sl.Insert(fooBarSub);
|
||||
sl.Insert(fooBazSub);
|
||||
sl.Insert(fooBarBazSub);
|
||||
|
||||
// ReverseMatch("foo") — only fooSub
|
||||
var r = sl.ReverseMatch("foo");
|
||||
r.PlainSubs.Length.ShouldBe(1);
|
||||
r.PlainSubs.ShouldContain(fooSub);
|
||||
|
||||
// ReverseMatch("bar") — only barSub
|
||||
r = sl.ReverseMatch("bar");
|
||||
r.PlainSubs.ShouldHaveSingleItem();
|
||||
r.PlainSubs.ShouldContain(barSub);
|
||||
|
||||
// ReverseMatch("*") — single-token subs: foo and bar
|
||||
r = sl.ReverseMatch("*");
|
||||
r.PlainSubs.Length.ShouldBe(2);
|
||||
r.PlainSubs.ShouldContain(fooSub);
|
||||
r.PlainSubs.ShouldContain(barSub);
|
||||
|
||||
// ReverseMatch("baz") — no match
|
||||
sl.ReverseMatch("baz").PlainSubs.ShouldBeEmpty();
|
||||
|
||||
// ReverseMatch("foo.*") — foo.bar and foo.baz
|
||||
r = sl.ReverseMatch("foo.*");
|
||||
r.PlainSubs.Length.ShouldBe(2);
|
||||
r.PlainSubs.ShouldContain(fooBarSub);
|
||||
r.PlainSubs.ShouldContain(fooBazSub);
|
||||
|
||||
// ReverseMatch("*.*") — same two
|
||||
r = sl.ReverseMatch("*.*");
|
||||
r.PlainSubs.Length.ShouldBe(2);
|
||||
r.PlainSubs.ShouldContain(fooBarSub);
|
||||
r.PlainSubs.ShouldContain(fooBazSub);
|
||||
|
||||
// ReverseMatch("*.bar") — only fooBarSub
|
||||
r = sl.ReverseMatch("*.bar");
|
||||
r.PlainSubs.ShouldHaveSingleItem();
|
||||
r.PlainSubs.ShouldContain(fooBarSub);
|
||||
|
||||
// ReverseMatch("bar.*") — no match
|
||||
sl.ReverseMatch("bar.*").PlainSubs.ShouldBeEmpty();
|
||||
|
||||
// ReverseMatch("foo.>") — 3 subs under foo
|
||||
r = sl.ReverseMatch("foo.>");
|
||||
r.PlainSubs.Length.ShouldBe(3);
|
||||
r.PlainSubs.ShouldContain(fooBarSub);
|
||||
r.PlainSubs.ShouldContain(fooBazSub);
|
||||
r.PlainSubs.ShouldContain(fooBarBazSub);
|
||||
|
||||
// ReverseMatch(">") — all 5 subs
|
||||
r = sl.ReverseMatch(">");
|
||||
r.PlainSubs.Length.ShouldBe(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ReverseMatch finds a subscription even when the query has extra wildcard
|
||||
/// tokens beyond what the stored pattern has.
|
||||
/// Ref: TestSublistReverseMatchWider (sublist_test.go:1508)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReverseMatch_wider_query()
|
||||
{
|
||||
var sl = new SubList();
|
||||
var sub = MakeSub("uplink.*.*.>");
|
||||
sl.Insert(sub);
|
||||
|
||||
sl.ReverseMatch("uplink.1.*.*.>").PlainSubs.ShouldHaveSingleItem();
|
||||
sl.ReverseMatch("uplink.1.2.3.>").PlainSubs.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Match with empty tokens (should yield no results)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Subjects with empty tokens (leading/trailing/double dots) never match any
|
||||
/// subscription, even when a catch-all '>' subscription is present.
|
||||
/// Ref: TestSublistMatchWithEmptyTokens (sublist_test.go:1522)
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(".foo")]
|
||||
[InlineData("..foo")]
|
||||
[InlineData("foo..")]
|
||||
[InlineData("foo.")]
|
||||
[InlineData("foo..bar")]
|
||||
[InlineData("foo...bar")]
|
||||
public void Match_with_empty_tokens_returns_empty(string badSubject)
|
||||
{
|
||||
// Ref: TestSublistMatchWithEmptyTokens (sublist_test.go:1522)
|
||||
var sl = new SubList();
|
||||
sl.Insert(MakeSub(">", sid: "1"));
|
||||
sl.Insert(MakeSub(">", queue: "queue", sid: "2"));
|
||||
|
||||
var r = sl.Match(badSubject);
|
||||
r.PlainSubs.ShouldBeEmpty();
|
||||
r.QueueSubs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Interest notification (adapted from Go's channel-based API to .NET events)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// The InterestChanged event fires when subscriptions are inserted or removed.
|
||||
/// Inserting the first subscriber fires LocalAdded; removing the last fires
|
||||
/// LocalRemoved. Adding a second subscriber does not fire a redundant event.
|
||||
/// Ref: TestSublistRegisterInterestNotification (sublist_test.go:1126) —
|
||||
/// the Go API uses RegisterNotification with a channel; the .NET port exposes
|
||||
/// an <see cref="InterestChange"/> event instead.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InterestChanged_fires_on_first_insert_and_last_remove()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
var events = new List<InterestChange>();
|
||||
sl.InterestChanged += e => events.Add(e);
|
||||
|
||||
var sub1 = MakeSub("foo", sid: "1");
|
||||
var sub2 = MakeSub("foo", sid: "2");
|
||||
|
||||
sl.Insert(sub1);
|
||||
events.Count.ShouldBe(1);
|
||||
events[0].Kind.ShouldBe(InterestChangeKind.LocalAdded);
|
||||
events[0].Subject.ShouldBe("foo");
|
||||
|
||||
sl.Insert(sub2);
|
||||
events.Count.ShouldBe(2); // second insert still fires (event per operation)
|
||||
|
||||
sl.Remove(sub1);
|
||||
events.Count.ShouldBe(3);
|
||||
events[2].Kind.ShouldBe(InterestChangeKind.LocalRemoved);
|
||||
|
||||
sl.Remove(sub2);
|
||||
events.Count.ShouldBe(4);
|
||||
events[3].Kind.ShouldBe(InterestChangeKind.LocalRemoved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// InterestChanged events are raised for queue subscriptions with the correct
|
||||
/// Queue field populated.
|
||||
/// Ref: TestSublistRegisterInterestNotification (queue sub section) (sublist_test.go:1321)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InterestChanged_carries_queue_name_for_queue_subs()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
var events = new List<InterestChange>();
|
||||
sl.InterestChanged += e => events.Add(e);
|
||||
|
||||
var qsub = MakeSub("foo.bar.baz", queue: "q1", sid: "1");
|
||||
sl.Insert(qsub);
|
||||
events[0].Queue.ShouldBe("q1");
|
||||
events[0].Subject.ShouldBe("foo.bar.baz");
|
||||
events[0].Kind.ShouldBe(InterestChangeKind.LocalAdded);
|
||||
|
||||
sl.Remove(qsub);
|
||||
events[1].Kind.ShouldBe(InterestChangeKind.LocalRemoved);
|
||||
events[1].Queue.ShouldBe("q1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RemoveBatch removes all specified subscriptions in a single operation.
|
||||
/// Unlike individual Remove calls, RemoveBatch performs the removal atomically
|
||||
/// under a single write lock and does not fire InterestChanged per element —
|
||||
/// it is optimised for bulk teardown (e.g. client disconnect).
|
||||
/// After the batch, Match confirms that all removed subjects are gone.
|
||||
/// Ref: TestSublistRegisterInterestNotification (batch insert/remove) (sublist_test.go:1311)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RemoveBatch_removes_all_and_subscription_count_drops_to_zero()
|
||||
{
|
||||
using var sl = new SubList();
|
||||
var inserts = new List<InterestChange>();
|
||||
sl.InterestChanged += e =>
|
||||
{
|
||||
if (e.Kind == InterestChangeKind.LocalAdded) inserts.Add(e);
|
||||
};
|
||||
|
||||
var subs = Enumerable.Range(1, 4)
|
||||
.Select(i => MakeSub("foo", sid: i.ToString()))
|
||||
.ToArray();
|
||||
foreach (var s in subs) sl.Insert(s);
|
||||
|
||||
inserts.Count.ShouldBe(4);
|
||||
sl.Count.ShouldBe(4u);
|
||||
|
||||
// RemoveBatch atomically removes all — count goes to zero
|
||||
sl.RemoveBatch(subs);
|
||||
sl.Count.ShouldBe(0u);
|
||||
sl.Match("foo").PlainSubs.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user