test(parity): port 373 Go tests across protocol and services subsystems (C11+E15)
Protocol (C11): - ClientProtocolGoParityTests: 45 tests (header stripping, tracing, limits, NRG) - ConsumerGoParityTests: 60 tests (filters, actions, pinned, priority groups) - JetStreamGoParityTests: 38 tests (stream CRUD, purge, mirror, retention) Services (E15): - MqttGoParityTests: 65 tests (packet parsing, QoS, retained, sessions) - WsGoParityTests: 58 tests (compression, JWT auth, frame encoding) - EventGoParityTests: 56 tests (event DTOs, serialization, health checks) - AccountGoParityTests: 28 tests (route mapping, system account, limits) - MonitorGoParityTests: 23 tests (connz filtering, pagination, sort) DB: 1,148/2,937 mapped (39.1%), up from 1,012 (34.5%)
This commit is contained in:
782
tests/NATS.Server.Tests/WebSocket/WsGoParityTests.cs
Normal file
782
tests/NATS.Server.Tests/WebSocket/WsGoParityTests.cs
Normal file
@@ -0,0 +1,782 @@
|
||||
// Port of Go server/websocket_test.go — WebSocket protocol parity tests.
|
||||
// Reference: golang/nats-server/server/websocket_test.go
|
||||
//
|
||||
// Tests cover: compression negotiation, JWT auth extraction (bearer/cookie/query),
|
||||
// frame encoding/decoding, origin checking, upgrade handshake, and close messages.
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using NATS.Server.WebSocket;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
/// <summary>
|
||||
/// Parity tests ported from Go server/websocket_test.go exercising WebSocket
|
||||
/// frame encoding, compression negotiation, origin checking, upgrade validation,
|
||||
/// and JWT authentication extraction.
|
||||
/// </summary>
|
||||
public class WsGoParityTests
|
||||
{
|
||||
// ========================================================================
|
||||
// TestWSIsControlFrame
|
||||
// Go reference: websocket_test.go:TestWSIsControlFrame
|
||||
// ========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData(WsConstants.CloseMessage, true)]
|
||||
[InlineData(WsConstants.PingMessage, true)]
|
||||
[InlineData(WsConstants.PongMessage, true)]
|
||||
[InlineData(WsConstants.TextMessage, false)]
|
||||
[InlineData(WsConstants.BinaryMessage, false)]
|
||||
[InlineData(WsConstants.ContinuationFrame, false)]
|
||||
public void IsControlFrame_CorrectClassification(int opcode, bool expected)
|
||||
{
|
||||
// Go: TestWSIsControlFrame websocket_test.go
|
||||
WsConstants.IsControlFrame(opcode).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TestWSUnmask
|
||||
// Go reference: websocket_test.go:TestWSUnmask
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Unmask_XorsWithKey()
|
||||
{
|
||||
// Go: TestWSUnmask — XOR unmasking with 4-byte key.
|
||||
var ri = new WsReadInfo(expectMask: true);
|
||||
var key = new byte[] { 0x12, 0x34, 0x56, 0x78 };
|
||||
ri.SetMaskKey(key);
|
||||
|
||||
var data = new byte[] { 0x12 ^ (byte)'H', 0x34 ^ (byte)'e', 0x56 ^ (byte)'l', 0x78 ^ (byte)'l', 0x12 ^ (byte)'o' };
|
||||
ri.Unmask(data);
|
||||
|
||||
Encoding.ASCII.GetString(data).ShouldBe("Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unmask_LargeBuffer_UsesOptimizedPath()
|
||||
{
|
||||
// Go: TestWSUnmask — optimized 8-byte chunk path for larger buffers.
|
||||
var ri = new WsReadInfo(expectMask: true);
|
||||
var key = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
|
||||
ri.SetMaskKey(key);
|
||||
|
||||
// Create a buffer large enough to trigger the optimized path (>= 16 bytes)
|
||||
var original = new byte[32];
|
||||
for (int i = 0; i < original.Length; i++)
|
||||
original[i] = (byte)(i + 1);
|
||||
|
||||
// Mask it
|
||||
var masked = new byte[original.Length];
|
||||
for (int i = 0; i < masked.Length; i++)
|
||||
masked[i] = (byte)(original[i] ^ key[i % 4]);
|
||||
|
||||
// Unmask
|
||||
ri.Unmask(masked);
|
||||
masked.ShouldBe(original);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TestWSCreateCloseMessage
|
||||
// Go reference: websocket_test.go:TestWSCreateCloseMessage
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void CreateCloseMessage_StatusAndBody()
|
||||
{
|
||||
// Go: TestWSCreateCloseMessage — close message has 2-byte status + body.
|
||||
var msg = WsFrameWriter.CreateCloseMessage(
|
||||
WsConstants.CloseStatusNormalClosure, "goodbye");
|
||||
|
||||
msg.Length.ShouldBeGreaterThan(2);
|
||||
var status = BinaryPrimitives.ReadUInt16BigEndian(msg);
|
||||
status.ShouldBe((ushort)WsConstants.CloseStatusNormalClosure);
|
||||
Encoding.UTF8.GetString(msg.AsSpan(2)).ShouldBe("goodbye");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateCloseMessage_LongBody_Truncated()
|
||||
{
|
||||
// Go: TestWSCreateCloseMessage — body truncated to MaxControlPayloadSize.
|
||||
var longBody = new string('x', 200);
|
||||
var msg = WsFrameWriter.CreateCloseMessage(
|
||||
WsConstants.CloseStatusGoingAway, longBody);
|
||||
|
||||
msg.Length.ShouldBeLessThanOrEqualTo(WsConstants.MaxControlPayloadSize);
|
||||
// Should end with "..."
|
||||
var body = Encoding.UTF8.GetString(msg.AsSpan(2));
|
||||
body.ShouldEndWith("...");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TestWSCreateFrameHeader
|
||||
// Go reference: websocket_test.go:TestWSCreateFrameHeader
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void CreateFrameHeader_SmallPayload_2ByteHeader()
|
||||
{
|
||||
// Go: TestWSCreateFrameHeader — payload <= 125 uses 2-byte header.
|
||||
var (header, key) = WsFrameWriter.CreateFrameHeader(
|
||||
useMasking: false, compressed: false,
|
||||
opcode: WsConstants.BinaryMessage, payloadLength: 50);
|
||||
|
||||
header.Length.ShouldBe(2);
|
||||
(header[0] & 0x0F).ShouldBe(WsConstants.BinaryMessage);
|
||||
(header[0] & WsConstants.FinalBit).ShouldBe(WsConstants.FinalBit);
|
||||
(header[1] & 0x7F).ShouldBe(50);
|
||||
key.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateFrameHeader_MediumPayload_4ByteHeader()
|
||||
{
|
||||
// Go: TestWSCreateFrameHeader — payload 126-65535 uses 4-byte header.
|
||||
var (header, key) = WsFrameWriter.CreateFrameHeader(
|
||||
useMasking: false, compressed: false,
|
||||
opcode: WsConstants.BinaryMessage, payloadLength: 1000);
|
||||
|
||||
header.Length.ShouldBe(4);
|
||||
(header[1] & 0x7F).ShouldBe(126);
|
||||
var payloadLen = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(2));
|
||||
payloadLen.ShouldBe((ushort)1000);
|
||||
key.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateFrameHeader_LargePayload_10ByteHeader()
|
||||
{
|
||||
// Go: TestWSCreateFrameHeader — payload >= 65536 uses 10-byte header.
|
||||
var (header, key) = WsFrameWriter.CreateFrameHeader(
|
||||
useMasking: false, compressed: false,
|
||||
opcode: WsConstants.BinaryMessage, payloadLength: 100000);
|
||||
|
||||
header.Length.ShouldBe(10);
|
||||
(header[1] & 0x7F).ShouldBe(127);
|
||||
var payloadLen = BinaryPrimitives.ReadUInt64BigEndian(header.AsSpan(2));
|
||||
payloadLen.ShouldBe(100000UL);
|
||||
key.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateFrameHeader_WithMasking_Adds4ByteKey()
|
||||
{
|
||||
// Go: TestWSCreateFrameHeader — masking adds 4-byte key to header.
|
||||
var (header, key) = WsFrameWriter.CreateFrameHeader(
|
||||
useMasking: true, compressed: false,
|
||||
opcode: WsConstants.BinaryMessage, payloadLength: 50);
|
||||
|
||||
header.Length.ShouldBe(6); // 2 base + 4 mask key
|
||||
(header[1] & WsConstants.MaskBit).ShouldBe(WsConstants.MaskBit);
|
||||
key.ShouldNotBeNull();
|
||||
key!.Length.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateFrameHeader_Compressed_SetsRsv1()
|
||||
{
|
||||
// Go: TestWSCreateFrameHeader — compressed frames have RSV1 bit set.
|
||||
var (header, _) = WsFrameWriter.CreateFrameHeader(
|
||||
useMasking: false, compressed: true,
|
||||
opcode: WsConstants.BinaryMessage, payloadLength: 50);
|
||||
|
||||
(header[0] & WsConstants.Rsv1Bit).ShouldBe(WsConstants.Rsv1Bit);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TestWSCheckOrigin
|
||||
// Go reference: websocket_test.go:TestWSCheckOrigin
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void OriginChecker_SameOrigin_Allowed()
|
||||
{
|
||||
// Go: TestWSCheckOrigin — same origin passes.
|
||||
var checker = new WsOriginChecker(sameOrigin: true, allowedOrigins: null);
|
||||
checker.CheckOrigin("http://localhost:4222", "localhost:4222", isTls: false).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginChecker_SameOrigin_Rejected()
|
||||
{
|
||||
// Go: TestWSCheckOrigin — different origin fails.
|
||||
var checker = new WsOriginChecker(sameOrigin: true, allowedOrigins: null);
|
||||
var result = checker.CheckOrigin("http://evil.com", "localhost:4222", isTls: false);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldContain("not same origin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginChecker_AllowedList_Allowed()
|
||||
{
|
||||
// Go: TestWSCheckOrigin — allowed origins list.
|
||||
var checker = new WsOriginChecker(sameOrigin: false, allowedOrigins: ["http://example.com"]);
|
||||
checker.CheckOrigin("http://example.com", "localhost:4222", isTls: false).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginChecker_AllowedList_Rejected()
|
||||
{
|
||||
// Go: TestWSCheckOrigin — origin not in allowed list.
|
||||
var checker = new WsOriginChecker(sameOrigin: false, allowedOrigins: ["http://example.com"]);
|
||||
var result = checker.CheckOrigin("http://evil.com", "localhost:4222", isTls: false);
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldContain("not in the allowed list");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginChecker_EmptyOrigin_Allowed()
|
||||
{
|
||||
// Go: TestWSCheckOrigin — empty origin (non-browser) is always allowed.
|
||||
var checker = new WsOriginChecker(sameOrigin: true, allowedOrigins: null);
|
||||
checker.CheckOrigin(null, "localhost:4222", isTls: false).ShouldBeNull();
|
||||
checker.CheckOrigin("", "localhost:4222", isTls: false).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginChecker_NoRestrictions_AllAllowed()
|
||||
{
|
||||
// Go: no restrictions means all origins pass.
|
||||
var checker = new WsOriginChecker(sameOrigin: false, allowedOrigins: null);
|
||||
checker.CheckOrigin("http://anything.com", "localhost:4222", isTls: false).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginChecker_AllowedWithPort()
|
||||
{
|
||||
// Go: TestWSSetOriginOptions — origins with explicit ports.
|
||||
var checker = new WsOriginChecker(sameOrigin: false, allowedOrigins: ["http://example.com:8080"]);
|
||||
checker.CheckOrigin("http://example.com:8080", "localhost", isTls: false).ShouldBeNull();
|
||||
checker.CheckOrigin("http://example.com", "localhost", isTls: false).ShouldNotBeNull(); // wrong port
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TestWSCompressNegotiation
|
||||
// Go reference: websocket_test.go:TestWSCompressNegotiation
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void CompressNegotiation_FullParams()
|
||||
{
|
||||
// Go: TestWSCompressNegotiation — full parameter negotiation.
|
||||
var result = WsDeflateNegotiator.Negotiate(
|
||||
"permessage-deflate; server_no_context_takeover; client_no_context_takeover; server_max_window_bits=10; client_max_window_bits=12");
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Value.ServerNoContextTakeover.ShouldBeTrue();
|
||||
result.Value.ClientNoContextTakeover.ShouldBeTrue();
|
||||
result.Value.ServerMaxWindowBits.ShouldBe(10);
|
||||
result.Value.ClientMaxWindowBits.ShouldBe(12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompressNegotiation_NoExtension_ReturnsNull()
|
||||
{
|
||||
// Go: TestWSCompressNegotiation — no permessage-deflate in header.
|
||||
WsDeflateNegotiator.Negotiate("x-webkit-deflate-frame").ShouldBeNull();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WS Upgrade — JWT extraction (bearer, cookie, query parameter)
|
||||
// Go reference: websocket_test.go:TestWSBasicAuth, TestWSBindToProperAccount
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_BearerJwt_ExtractedFromAuthHeader()
|
||||
{
|
||||
// Go: TestWSBasicAuth — JWT extracted from Authorization: Bearer header.
|
||||
var request = BuildValidRequest(extraHeaders:
|
||||
"Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.test_jwt_token\r\n");
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Jwt.ShouldBe("eyJhbGciOiJIUzI1NiJ9.test_jwt_token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_CookieJwt_ExtractedFromCookie()
|
||||
{
|
||||
// Go: TestWSBindToProperAccount — JWT extracted from cookie when configured.
|
||||
var request = BuildValidRequest(extraHeaders:
|
||||
"Cookie: jwt=eyJhbGciOiJIUzI1NiJ9.cookie_jwt; other=value\r\n");
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true, JwtCookie = "jwt" };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.CookieJwt.ShouldBe("eyJhbGciOiJIUzI1NiJ9.cookie_jwt");
|
||||
// Cookie JWT becomes fallback JWT
|
||||
result.Jwt.ShouldBe("eyJhbGciOiJIUzI1NiJ9.cookie_jwt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_QueryJwt_ExtractedFromQueryParam()
|
||||
{
|
||||
// Go: JWT extracted from query parameter when no auth header or cookie.
|
||||
var request = BuildValidRequest(
|
||||
path: "/?jwt=eyJhbGciOiJIUzI1NiJ9.query_jwt");
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Jwt.ShouldBe("eyJhbGciOiJIUzI1NiJ9.query_jwt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_JwtPriority_BearerOverCookieOverQuery()
|
||||
{
|
||||
// Go: Authorization header takes priority over cookie and query.
|
||||
var request = BuildValidRequest(
|
||||
path: "/?jwt=query_token",
|
||||
extraHeaders: "Authorization: Bearer bearer_token\r\nCookie: jwt=cookie_token\r\n");
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true, JwtCookie = "jwt" };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Jwt.ShouldBe("bearer_token");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TestWSXForwardedFor
|
||||
// Go reference: websocket_test.go:TestWSXForwardedFor
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_XForwardedFor_ExtractsClientIp()
|
||||
{
|
||||
// Go: TestWSXForwardedFor — X-Forwarded-For header extracts first IP.
|
||||
var request = BuildValidRequest(extraHeaders:
|
||||
"X-Forwarded-For: 192.168.1.100, 10.0.0.1\r\n");
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.ClientIp.ShouldBe("192.168.1.100");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TestWSUpgradeValidationErrors
|
||||
// Go reference: websocket_test.go:TestWSUpgradeValidationErrors
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_MissingHost_Fails()
|
||||
{
|
||||
// Go: TestWSUpgradeValidationErrors — missing Host header.
|
||||
var request = "GET / HTTP/1.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n";
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_MissingUpgradeHeader_Fails()
|
||||
{
|
||||
// Go: TestWSUpgradeValidationErrors — missing Upgrade header.
|
||||
var request = "GET / HTTP/1.1\r\nHost: localhost:4222\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n";
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_MissingKey_Fails()
|
||||
{
|
||||
// Go: TestWSUpgradeValidationErrors — missing Sec-WebSocket-Key.
|
||||
var request = "GET / HTTP/1.1\r\nHost: localhost:4222\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\n\r\n";
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_WrongVersion_Fails()
|
||||
{
|
||||
// Go: TestWSUpgradeValidationErrors — wrong WebSocket version.
|
||||
var request = BuildValidRequest(versionOverride: "12");
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TestWSSetHeader
|
||||
// Go reference: websocket_test.go:TestWSSetHeader
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_CustomHeaders_IncludedInResponse()
|
||||
{
|
||||
// Go: TestWSSetHeader — custom headers added to upgrade response.
|
||||
var request = BuildValidRequest();
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions
|
||||
{
|
||||
NoTls = true,
|
||||
Headers = new Dictionary<string, string> { ["X-Custom"] = "test-value" },
|
||||
};
|
||||
await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
var response = ReadResponse(output);
|
||||
response.ShouldContain("X-Custom: test-value");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TestWSWebrowserClient
|
||||
// Go reference: websocket_test.go:TestWSWebrowserClient
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_BrowserUserAgent_DetectedAsBrowser()
|
||||
{
|
||||
// Go: TestWSWebrowserClient — Mozilla user-agent detected as browser.
|
||||
var request = BuildValidRequest(extraHeaders:
|
||||
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\r\n");
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Browser.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_NonBrowserUserAgent_NotDetected()
|
||||
{
|
||||
// Go: non-browser user agent is not flagged.
|
||||
var request = BuildValidRequest(extraHeaders:
|
||||
"User-Agent: nats-client/1.0\r\n");
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Browser.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TestWSCompressionBasic
|
||||
// Go reference: websocket_test.go:TestWSCompressionBasic
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Compression_RoundTrip()
|
||||
{
|
||||
// Go: TestWSCompressionBasic — compress then decompress returns original.
|
||||
var original = "Hello, WebSocket compression test! This is a reasonably long string."u8.ToArray();
|
||||
|
||||
var compressed = WsCompression.Compress(original);
|
||||
var decompressed = WsCompression.Decompress([compressed], maxPayload: 1024 * 1024);
|
||||
|
||||
decompressed.ShouldBe(original);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compression_SmallData_StillWorks()
|
||||
{
|
||||
// Go: even very small data can be compressed/decompressed.
|
||||
var original = "Hi"u8.ToArray();
|
||||
|
||||
var compressed = WsCompression.Compress(original);
|
||||
var decompressed = WsCompression.Decompress([compressed], maxPayload: 1024);
|
||||
|
||||
decompressed.ShouldBe(original);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compression_EmptyData()
|
||||
{
|
||||
var compressed = WsCompression.Compress(ReadOnlySpan<byte>.Empty);
|
||||
var decompressed = WsCompression.Decompress([compressed], maxPayload: 1024);
|
||||
decompressed.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TestWSDecompressLimit
|
||||
// Go reference: websocket_test.go:TestWSDecompressLimit
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Decompress_ExceedsMaxPayload_Throws()
|
||||
{
|
||||
// Go: TestWSDecompressLimit — decompressed data exceeding max payload throws.
|
||||
// Create data larger than the limit
|
||||
var large = new byte[10000];
|
||||
for (int i = 0; i < large.Length; i++) large[i] = (byte)(i % 256);
|
||||
|
||||
var compressed = WsCompression.Compress(large);
|
||||
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
WsCompression.Decompress([compressed], maxPayload: 100));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// MaskBuf / MaskBufs
|
||||
// Go reference: websocket_test.go TestWSFrameOutbound
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void MaskBuf_XorsInPlace()
|
||||
{
|
||||
// Go: TestWSFrameOutbound — masking XORs buffer with key.
|
||||
var key = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
|
||||
var data = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 };
|
||||
var expected = new byte[] { 0x01 ^ 0xAA, 0x02 ^ 0xBB, 0x03 ^ 0xCC, 0x04 ^ 0xDD, 0x05 ^ 0xAA };
|
||||
|
||||
WsFrameWriter.MaskBuf(key, data);
|
||||
data.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskBuf_DoubleApply_RestoresOriginal()
|
||||
{
|
||||
// Go: masking is its own inverse.
|
||||
var key = new byte[] { 0x12, 0x34, 0x56, 0x78 };
|
||||
var original = "Hello World"u8.ToArray();
|
||||
var copy = original.ToArray();
|
||||
|
||||
WsFrameWriter.MaskBuf(key, copy);
|
||||
copy.ShouldNotBe(original);
|
||||
|
||||
WsFrameWriter.MaskBuf(key, copy);
|
||||
copy.ShouldBe(original);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// MapCloseStatus
|
||||
// Go reference: websocket_test.go TestWSEnqueueCloseMsg
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void MapCloseStatus_ClientClosed_NormalClosure()
|
||||
{
|
||||
// Go: TestWSEnqueueCloseMsg — client-initiated close maps to 1000.
|
||||
WsFrameWriter.MapCloseStatus(ClientClosedReason.ClientClosed)
|
||||
.ShouldBe(WsConstants.CloseStatusNormalClosure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCloseStatus_AuthViolation_PolicyViolation()
|
||||
{
|
||||
// Go: TestWSEnqueueCloseMsg — auth violation maps to 1008.
|
||||
WsFrameWriter.MapCloseStatus(ClientClosedReason.AuthenticationViolation)
|
||||
.ShouldBe(WsConstants.CloseStatusPolicyViolation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCloseStatus_ProtocolError_ProtocolError()
|
||||
{
|
||||
WsFrameWriter.MapCloseStatus(ClientClosedReason.ProtocolViolation)
|
||||
.ShouldBe(WsConstants.CloseStatusProtocolError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCloseStatus_ServerShutdown_GoingAway()
|
||||
{
|
||||
WsFrameWriter.MapCloseStatus(ClientClosedReason.ServerShutdown)
|
||||
.ShouldBe(WsConstants.CloseStatusGoingAway);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCloseStatus_MaxPayloadExceeded_MessageTooBig()
|
||||
{
|
||||
WsFrameWriter.MapCloseStatus(ClientClosedReason.MaxPayloadExceeded)
|
||||
.ShouldBe(WsConstants.CloseStatusMessageTooBig);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WsUpgrade.ComputeAcceptKey
|
||||
// Go reference: websocket_test.go — RFC 6455 example
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ComputeAcceptKey_Rfc6455Example()
|
||||
{
|
||||
// RFC 6455 Section 4.2.2 example
|
||||
var accept = WsUpgrade.ComputeAcceptKey("dGhlIHNhbXBsZSBub25jZQ==");
|
||||
accept.ShouldBe("s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WsUpgrade — path-based client kind detection
|
||||
// Go reference: websocket_test.go TestWSWebrowserClient
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_LeafNodePath_DetectedAsLeaf()
|
||||
{
|
||||
var request = BuildValidRequest(path: "/leafnode");
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Kind.ShouldBe(WsClientKind.Leaf);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_MqttPath_DetectedAsMqtt()
|
||||
{
|
||||
var request = BuildValidRequest(path: "/mqtt");
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Kind.ShouldBe(WsClientKind.Mqtt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_RootPath_DetectedAsClient()
|
||||
{
|
||||
var request = BuildValidRequest(path: "/");
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Kind.ShouldBe(WsClientKind.Client);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WsUpgrade — cookie extraction
|
||||
// Go reference: websocket_test.go TestWSNoAuthUserValidation
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_Cookies_Extracted()
|
||||
{
|
||||
// Go: TestWSNoAuthUserValidation — username/password/token from cookies.
|
||||
var request = BuildValidRequest(extraHeaders:
|
||||
"Cookie: nats_user=admin; nats_pass=secret; nats_token=tok123\r\n");
|
||||
var (input, output) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions
|
||||
{
|
||||
NoTls = true,
|
||||
UsernameCookie = "nats_user",
|
||||
PasswordCookie = "nats_pass",
|
||||
TokenCookie = "nats_token",
|
||||
};
|
||||
var result = await WsUpgrade.TryUpgradeAsync(input, output, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.CookieUsername.ShouldBe("admin");
|
||||
result.CookiePassword.ShouldBe("secret");
|
||||
result.CookieToken.ShouldBe("tok123");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ExtractBearerToken
|
||||
// Go reference: websocket_test.go — bearer token extraction
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ExtractBearerToken_WithPrefix()
|
||||
{
|
||||
WsUpgrade.ExtractBearerToken("Bearer my-token").ShouldBe("my-token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBearerToken_WithoutPrefix()
|
||||
{
|
||||
WsUpgrade.ExtractBearerToken("my-token").ShouldBe("my-token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBearerToken_Empty_ReturnsNull()
|
||||
{
|
||||
WsUpgrade.ExtractBearerToken("").ShouldBeNull();
|
||||
WsUpgrade.ExtractBearerToken(null).ShouldBeNull();
|
||||
WsUpgrade.ExtractBearerToken(" ").ShouldBeNull();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ParseQueryString
|
||||
// Go reference: websocket_test.go — query parameter parsing
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ParseQueryString_MultipleParams()
|
||||
{
|
||||
var result = WsUpgrade.ParseQueryString("?jwt=abc&user=admin&pass=secret");
|
||||
|
||||
result["jwt"].ShouldBe("abc");
|
||||
result["user"].ShouldBe("admin");
|
||||
result["pass"].ShouldBe("secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseQueryString_UrlEncoded()
|
||||
{
|
||||
var result = WsUpgrade.ParseQueryString("?key=hello%20world");
|
||||
result["key"].ShouldBe("hello world");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseQueryString_NoQuestionMark()
|
||||
{
|
||||
var result = WsUpgrade.ParseQueryString("jwt=token123");
|
||||
result["jwt"].ShouldBe("token123");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Helpers
|
||||
// ========================================================================
|
||||
|
||||
private static string BuildValidRequest(string path = "/", string? extraHeaders = null, string? versionOverride = null)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"GET {path} HTTP/1.1\r\n");
|
||||
sb.Append("Host: localhost:4222\r\n");
|
||||
sb.Append("Upgrade: websocket\r\n");
|
||||
sb.Append("Connection: Upgrade\r\n");
|
||||
sb.Append("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n");
|
||||
sb.Append($"Sec-WebSocket-Version: {versionOverride ?? "13"}\r\n");
|
||||
if (extraHeaders != null)
|
||||
sb.Append(extraHeaders);
|
||||
sb.Append("\r\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static (Stream input, MemoryStream output) CreateStreamPair(string httpRequest)
|
||||
{
|
||||
var inputBytes = Encoding.ASCII.GetBytes(httpRequest);
|
||||
return (new MemoryStream(inputBytes), new MemoryStream());
|
||||
}
|
||||
|
||||
private static string ReadResponse(MemoryStream output)
|
||||
{
|
||||
output.Position = 0;
|
||||
return Encoding.ASCII.GetString(output.ToArray());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user