// 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; /// /// Parity tests ported from Go server/websocket_test.go exercising WebSocket /// frame encoding, compression negotiation, origin checking, upgrade validation, /// and JWT authentication extraction. /// 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 { ["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.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(() => 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()); } }