Merge branch 'feature/websocket'
# Conflicts: # differences.md
This commit is contained in:
26
tests/NATS.Server.Tests/WebSocket/WebSocketOptionsTests.cs
Normal file
26
tests/NATS.Server.Tests/WebSocket/WebSocketOptionsTests.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
public class WebSocketOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultOptions_PortIsNegativeOne_Disabled()
|
||||
{
|
||||
var opts = new WebSocketOptions();
|
||||
opts.Port.ShouldBe(-1);
|
||||
opts.Host.ShouldBe("0.0.0.0");
|
||||
opts.Compression.ShouldBeFalse();
|
||||
opts.NoTls.ShouldBeFalse();
|
||||
opts.HandshakeTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NatsOptions_HasWebSocketProperty()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
opts.WebSocket.ShouldNotBeNull();
|
||||
opts.WebSocket.Port.ShouldBe(-1);
|
||||
}
|
||||
}
|
||||
58
tests/NATS.Server.Tests/WebSocket/WsCompressionTests.cs
Normal file
58
tests/NATS.Server.Tests/WebSocket/WsCompressionTests.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using NATS.Server.WebSocket;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
public class WsCompressionTests
|
||||
{
|
||||
[Fact]
|
||||
public void CompressDecompress_RoundTrip()
|
||||
{
|
||||
var original = "Hello, WebSocket compression test! This is long enough to compress."u8.ToArray();
|
||||
var compressed = WsCompression.Compress(original);
|
||||
compressed.ShouldNotBeNull();
|
||||
compressed.Length.ShouldBeGreaterThan(0);
|
||||
|
||||
var decompressed = WsCompression.Decompress([compressed], maxPayload: 4096);
|
||||
decompressed.ShouldBe(original);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decompress_ExceedsMaxPayload_Throws()
|
||||
{
|
||||
var original = new byte[1000];
|
||||
Random.Shared.NextBytes(original);
|
||||
var compressed = WsCompression.Compress(original);
|
||||
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
WsCompression.Decompress([compressed], maxPayload: 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compress_RemovesTrailing4Bytes()
|
||||
{
|
||||
var data = new byte[200];
|
||||
Random.Shared.NextBytes(data);
|
||||
var compressed = WsCompression.Compress(data);
|
||||
|
||||
// The compressed data should be valid for decompression when we add the trailer back
|
||||
var decompressed = WsCompression.Decompress([compressed], maxPayload: 4096);
|
||||
decompressed.ShouldBe(data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decompress_MultipleBuffers()
|
||||
{
|
||||
var original = new byte[500];
|
||||
Random.Shared.NextBytes(original);
|
||||
var compressed = WsCompression.Compress(original);
|
||||
|
||||
// Split compressed data into multiple chunks
|
||||
int mid = compressed.Length / 2;
|
||||
var chunk1 = compressed[..mid];
|
||||
var chunk2 = compressed[mid..];
|
||||
|
||||
var decompressed = WsCompression.Decompress([chunk1, chunk2], maxPayload: 4096);
|
||||
decompressed.ShouldBe(original);
|
||||
}
|
||||
}
|
||||
124
tests/NATS.Server.Tests/WebSocket/WsConnectionTests.cs
Normal file
124
tests/NATS.Server.Tests/WebSocket/WsConnectionTests.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Buffers.Binary;
|
||||
using NATS.Server.WebSocket;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
public class WsConnectionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAsync_DecodesFrameAndReturnsPayload()
|
||||
{
|
||||
var payload = "SUB test 1\r\n"u8.ToArray();
|
||||
var frame = BuildUnmaskedFrame(payload);
|
||||
var inner = new MemoryStream(frame);
|
||||
var ws = new WsConnection(inner, compress: false, maskRead: false, maskWrite: false, browser: false, noCompFrag: false);
|
||||
|
||||
var buf = new byte[256];
|
||||
int n = await ws.ReadAsync(buf);
|
||||
|
||||
n.ShouldBe(payload.Length);
|
||||
buf[..n].ShouldBe(payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_FramesPayload()
|
||||
{
|
||||
var inner = new MemoryStream();
|
||||
var ws = new WsConnection(inner, compress: false, maskRead: false, maskWrite: false, browser: false, noCompFrag: false);
|
||||
|
||||
var payload = "MSG test 1 5\r\nHello\r\n"u8.ToArray();
|
||||
await ws.WriteAsync(payload);
|
||||
await ws.FlushAsync();
|
||||
|
||||
inner.Position = 0;
|
||||
var written = inner.ToArray();
|
||||
// First 2 bytes should be WS frame header
|
||||
(written[0] & WsConstants.FinalBit).ShouldNotBe(0);
|
||||
(written[0] & 0x0F).ShouldBe(WsConstants.BinaryMessage);
|
||||
int len = written[1] & 0x7F;
|
||||
len.ShouldBe(payload.Length);
|
||||
written[2..].ShouldBe(payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_WithCompression_CompressesLargePayload()
|
||||
{
|
||||
var inner = new MemoryStream();
|
||||
var ws = new WsConnection(inner, compress: true, maskRead: false, maskWrite: false, browser: false, noCompFrag: false);
|
||||
|
||||
var payload = new byte[200];
|
||||
Array.Fill<byte>(payload, 0x41); // 'A' repeated - very compressible
|
||||
await ws.WriteAsync(payload);
|
||||
await ws.FlushAsync();
|
||||
|
||||
inner.Position = 0;
|
||||
var written = inner.ToArray();
|
||||
// RSV1 bit should be set for compressed frame
|
||||
(written[0] & WsConstants.Rsv1Bit).ShouldNotBe(0);
|
||||
// Compressed size should be less than original
|
||||
written.Length.ShouldBeLessThan(payload.Length + 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_SmallPayload_NotCompressedEvenWhenEnabled()
|
||||
{
|
||||
var inner = new MemoryStream();
|
||||
var ws = new WsConnection(inner, compress: true, maskRead: false, maskWrite: false, browser: false, noCompFrag: false);
|
||||
|
||||
var payload = "Hi"u8.ToArray(); // Below CompressThreshold
|
||||
await ws.WriteAsync(payload);
|
||||
await ws.FlushAsync();
|
||||
|
||||
inner.Position = 0;
|
||||
var written = inner.ToArray();
|
||||
// RSV1 bit should NOT be set for small payloads
|
||||
(written[0] & WsConstants.Rsv1Bit).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_DecodesMaskedFrame()
|
||||
{
|
||||
var payload = "CONNECT {}\r\n"u8.ToArray();
|
||||
var (header, _) = WsFrameWriter.CreateFrameHeader(
|
||||
useMasking: true, compressed: false,
|
||||
opcode: WsConstants.BinaryMessage, payloadLength: payload.Length);
|
||||
var maskKey = header[^4..];
|
||||
WsFrameWriter.MaskBuf(maskKey, payload);
|
||||
|
||||
var frame = new byte[header.Length + payload.Length];
|
||||
header.CopyTo(frame, 0);
|
||||
payload.CopyTo(frame, header.Length);
|
||||
|
||||
var inner = new MemoryStream(frame);
|
||||
var ws = new WsConnection(inner, compress: false, maskRead: true, maskWrite: false, browser: false, noCompFrag: false);
|
||||
|
||||
var buf = new byte[256];
|
||||
int n = await ws.ReadAsync(buf);
|
||||
|
||||
n.ShouldBe("CONNECT {}\r\n".Length);
|
||||
System.Text.Encoding.ASCII.GetString(buf, 0, n).ShouldBe("CONNECT {}\r\n");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_ReturnsZero_OnEndOfStream()
|
||||
{
|
||||
// Empty stream should return 0 (true end of stream)
|
||||
var inner = new MemoryStream([]);
|
||||
var ws = new WsConnection(inner, compress: false, maskRead: false, maskWrite: false, browser: false, noCompFrag: false);
|
||||
|
||||
var buf = new byte[256];
|
||||
int n = await ws.ReadAsync(buf);
|
||||
n.ShouldBe(0);
|
||||
}
|
||||
|
||||
private static byte[] BuildUnmaskedFrame(byte[] payload)
|
||||
{
|
||||
var header = new byte[2];
|
||||
header[0] = (byte)(WsConstants.FinalBit | WsConstants.BinaryMessage);
|
||||
header[1] = (byte)payload.Length;
|
||||
var frame = new byte[2 + payload.Length];
|
||||
header.CopyTo(frame, 0);
|
||||
payload.CopyTo(frame, 2);
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
53
tests/NATS.Server.Tests/WebSocket/WsConstantsTests.cs
Normal file
53
tests/NATS.Server.Tests/WebSocket/WsConstantsTests.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using NATS.Server.WebSocket;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
public class WsConstantsTests
|
||||
{
|
||||
[Fact]
|
||||
public void OpCodes_MatchRfc6455()
|
||||
{
|
||||
WsConstants.TextMessage.ShouldBe(1);
|
||||
WsConstants.BinaryMessage.ShouldBe(2);
|
||||
WsConstants.CloseMessage.ShouldBe(8);
|
||||
WsConstants.PingMessage.ShouldBe(9);
|
||||
WsConstants.PongMessage.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrameBits_MatchRfc6455()
|
||||
{
|
||||
WsConstants.FinalBit.ShouldBe((byte)0x80);
|
||||
WsConstants.Rsv1Bit.ShouldBe((byte)0x40);
|
||||
WsConstants.MaskBit.ShouldBe((byte)0x80);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseStatusCodes_MatchRfc6455()
|
||||
{
|
||||
WsConstants.CloseStatusNormalClosure.ShouldBe(1000);
|
||||
WsConstants.CloseStatusGoingAway.ShouldBe(1001);
|
||||
WsConstants.CloseStatusProtocolError.ShouldBe(1002);
|
||||
WsConstants.CloseStatusPolicyViolation.ShouldBe(1008);
|
||||
WsConstants.CloseStatusMessageTooBig.ShouldBe(1009);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WsConstants.CloseMessage)]
|
||||
[InlineData(WsConstants.PingMessage)]
|
||||
[InlineData(WsConstants.PongMessage)]
|
||||
public void IsControlFrame_True(int opcode)
|
||||
{
|
||||
WsConstants.IsControlFrame(opcode).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WsConstants.TextMessage)]
|
||||
[InlineData(WsConstants.BinaryMessage)]
|
||||
[InlineData(0)]
|
||||
public void IsControlFrame_False(int opcode)
|
||||
{
|
||||
WsConstants.IsControlFrame(opcode).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
163
tests/NATS.Server.Tests/WebSocket/WsFrameReadTests.cs
Normal file
163
tests/NATS.Server.Tests/WebSocket/WsFrameReadTests.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using System.Buffers.Binary;
|
||||
using NATS.Server.WebSocket;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
public class WsFrameReadTests
|
||||
{
|
||||
/// <summary>Helper: build a single unmasked binary frame.</summary>
|
||||
private static byte[] BuildFrame(byte[] payload, bool fin = true, bool compressed = false, int opcode = WsConstants.BinaryMessage, bool mask = false, byte[]? maskKey = null)
|
||||
{
|
||||
int payloadLen = payload.Length;
|
||||
byte b0 = (byte)opcode;
|
||||
if (fin) b0 |= WsConstants.FinalBit;
|
||||
if (compressed) b0 |= WsConstants.Rsv1Bit;
|
||||
byte b1 = 0;
|
||||
if (mask) b1 |= WsConstants.MaskBit;
|
||||
|
||||
byte[] lenBytes;
|
||||
if (payloadLen <= 125)
|
||||
{
|
||||
lenBytes = [(byte)(b1 | (byte)payloadLen)];
|
||||
}
|
||||
else if (payloadLen < 65536)
|
||||
{
|
||||
lenBytes = new byte[3];
|
||||
lenBytes[0] = (byte)(b1 | 126);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(lenBytes.AsSpan(1), (ushort)payloadLen);
|
||||
}
|
||||
else
|
||||
{
|
||||
lenBytes = new byte[9];
|
||||
lenBytes[0] = (byte)(b1 | 127);
|
||||
BinaryPrimitives.WriteUInt64BigEndian(lenBytes.AsSpan(1), (ulong)payloadLen);
|
||||
}
|
||||
|
||||
int totalLen = 1 + lenBytes.Length + (mask ? 4 : 0) + payloadLen;
|
||||
var frame = new byte[totalLen];
|
||||
frame[0] = b0;
|
||||
lenBytes.CopyTo(frame.AsSpan(1));
|
||||
int pos = 1 + lenBytes.Length;
|
||||
if (mask && maskKey != null)
|
||||
{
|
||||
maskKey.CopyTo(frame.AsSpan(pos));
|
||||
pos += 4;
|
||||
var maskedPayload = payload.ToArray();
|
||||
WsFrameWriter.MaskBuf(maskKey, maskedPayload);
|
||||
maskedPayload.CopyTo(frame.AsSpan(pos));
|
||||
}
|
||||
else
|
||||
{
|
||||
payload.CopyTo(frame.AsSpan(pos));
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadSingleUnmaskedFrame()
|
||||
{
|
||||
var payload = "Hello"u8.ToArray();
|
||||
var frame = BuildFrame(payload);
|
||||
|
||||
var readInfo = new WsReadInfo(expectMask: false);
|
||||
var stream = new MemoryStream(frame);
|
||||
var result = WsReadInfo.ReadFrames(readInfo, stream, frame.Length, maxPayload: 1024);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].ShouldBe(payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadMaskedFrame()
|
||||
{
|
||||
var payload = "Hello"u8.ToArray();
|
||||
byte[] key = [0x37, 0xFA, 0x21, 0x3D];
|
||||
var frame = BuildFrame(payload, mask: true, maskKey: key);
|
||||
|
||||
var readInfo = new WsReadInfo(expectMask: true);
|
||||
var stream = new MemoryStream(frame);
|
||||
var result = WsReadInfo.ReadFrames(readInfo, stream, frame.Length, maxPayload: 1024);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].ShouldBe(payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read16BitLengthFrame()
|
||||
{
|
||||
var payload = new byte[200];
|
||||
Random.Shared.NextBytes(payload);
|
||||
var frame = BuildFrame(payload);
|
||||
|
||||
var readInfo = new WsReadInfo(expectMask: false);
|
||||
var stream = new MemoryStream(frame);
|
||||
var result = WsReadInfo.ReadFrames(readInfo, stream, frame.Length, maxPayload: 1024);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].ShouldBe(payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadPingFrame_ReturnsPongAction()
|
||||
{
|
||||
var frame = BuildFrame([], opcode: WsConstants.PingMessage);
|
||||
|
||||
var readInfo = new WsReadInfo(expectMask: false);
|
||||
var stream = new MemoryStream(frame);
|
||||
var result = WsReadInfo.ReadFrames(readInfo, stream, frame.Length, maxPayload: 1024);
|
||||
|
||||
result.Count.ShouldBe(0); // control frames don't produce payload
|
||||
readInfo.PendingControlFrames.Count.ShouldBe(1);
|
||||
readInfo.PendingControlFrames[0].Opcode.ShouldBe(WsConstants.PongMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadCloseFrame_ReturnsCloseAction()
|
||||
{
|
||||
var closePayload = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(closePayload, 1000);
|
||||
var frame = BuildFrame(closePayload, opcode: WsConstants.CloseMessage);
|
||||
|
||||
var readInfo = new WsReadInfo(expectMask: false);
|
||||
var stream = new MemoryStream(frame);
|
||||
var result = WsReadInfo.ReadFrames(readInfo, stream, frame.Length, maxPayload: 1024);
|
||||
|
||||
result.Count.ShouldBe(0);
|
||||
readInfo.CloseReceived.ShouldBeTrue();
|
||||
readInfo.CloseStatus.ShouldBe(1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadPongFrame_NoAction()
|
||||
{
|
||||
var frame = BuildFrame([], opcode: WsConstants.PongMessage);
|
||||
|
||||
var readInfo = new WsReadInfo(expectMask: false);
|
||||
var stream = new MemoryStream(frame);
|
||||
var result = WsReadInfo.ReadFrames(readInfo, stream, frame.Length, maxPayload: 1024);
|
||||
|
||||
result.Count.ShouldBe(0);
|
||||
readInfo.PendingControlFrames.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unmask_Optimized_8ByteChunks()
|
||||
{
|
||||
byte[] key = [0xAA, 0xBB, 0xCC, 0xDD];
|
||||
var original = new byte[32];
|
||||
Random.Shared.NextBytes(original);
|
||||
var masked = original.ToArray();
|
||||
|
||||
// Mask it
|
||||
for (int i = 0; i < masked.Length; i++)
|
||||
masked[i] ^= key[i & 3];
|
||||
|
||||
// Unmask using the state machine
|
||||
var info = new WsReadInfo(expectMask: true);
|
||||
info.SetMaskKey(key);
|
||||
info.Unmask(masked);
|
||||
|
||||
masked.ShouldBe(original);
|
||||
}
|
||||
}
|
||||
152
tests/NATS.Server.Tests/WebSocket/WsFrameWriterTests.cs
Normal file
152
tests/NATS.Server.Tests/WebSocket/WsFrameWriterTests.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using System.Buffers.Binary;
|
||||
using NATS.Server.WebSocket;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
public class WsFrameWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateFrameHeader_SmallPayload_7BitLength()
|
||||
{
|
||||
var (header, _) = WsFrameWriter.CreateFrameHeader(
|
||||
useMasking: false, compressed: false,
|
||||
opcode: WsConstants.BinaryMessage, payloadLength: 100);
|
||||
header.Length.ShouldBe(2);
|
||||
(header[0] & WsConstants.FinalBit).ShouldNotBe(0); // FIN set
|
||||
(header[0] & 0x0F).ShouldBe(WsConstants.BinaryMessage);
|
||||
(header[1] & 0x7F).ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateFrameHeader_MediumPayload_16BitLength()
|
||||
{
|
||||
var (header, _) = WsFrameWriter.CreateFrameHeader(
|
||||
useMasking: false, compressed: false,
|
||||
opcode: WsConstants.BinaryMessage, payloadLength: 1000);
|
||||
header.Length.ShouldBe(4);
|
||||
(header[1] & 0x7F).ShouldBe(126);
|
||||
BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(2)).ShouldBe((ushort)1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateFrameHeader_LargePayload_64BitLength()
|
||||
{
|
||||
var (header, _) = WsFrameWriter.CreateFrameHeader(
|
||||
useMasking: false, compressed: false,
|
||||
opcode: WsConstants.BinaryMessage, payloadLength: 70000);
|
||||
header.Length.ShouldBe(10);
|
||||
(header[1] & 0x7F).ShouldBe(127);
|
||||
BinaryPrimitives.ReadUInt64BigEndian(header.AsSpan(2)).ShouldBe(70000UL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateFrameHeader_WithMasking_Adds4ByteKey()
|
||||
{
|
||||
var (header, key) = WsFrameWriter.CreateFrameHeader(
|
||||
useMasking: true, compressed: false,
|
||||
opcode: WsConstants.BinaryMessage, payloadLength: 10);
|
||||
header.Length.ShouldBe(6); // 2 header + 4 mask key
|
||||
(header[1] & WsConstants.MaskBit).ShouldNotBe(0);
|
||||
key.ShouldNotBeNull();
|
||||
key.Length.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateFrameHeader_Compressed_SetsRsv1Bit()
|
||||
{
|
||||
var (header, _) = WsFrameWriter.CreateFrameHeader(
|
||||
useMasking: false, compressed: true,
|
||||
opcode: WsConstants.BinaryMessage, payloadLength: 10);
|
||||
(header[0] & WsConstants.Rsv1Bit).ShouldNotBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskBuf_XorsCorrectly()
|
||||
{
|
||||
byte[] key = [0xAA, 0xBB, 0xCC, 0xDD];
|
||||
byte[] data = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06];
|
||||
byte[] expected = new byte[data.Length];
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
expected[i] = (byte)(data[i] ^ key[i & 3]);
|
||||
|
||||
WsFrameWriter.MaskBuf(key, data);
|
||||
data.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskBuf_RoundTrip()
|
||||
{
|
||||
byte[] key = [0x12, 0x34, 0x56, 0x78];
|
||||
byte[] original = "Hello, WebSocket!"u8.ToArray();
|
||||
var data = original.ToArray();
|
||||
|
||||
WsFrameWriter.MaskBuf(key, data);
|
||||
data.ShouldNotBe(original);
|
||||
WsFrameWriter.MaskBuf(key, data);
|
||||
data.ShouldBe(original);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateCloseMessage_WithStatusAndBody()
|
||||
{
|
||||
var msg = WsFrameWriter.CreateCloseMessage(1000, "normal closure");
|
||||
msg.Length.ShouldBe(2 + "normal closure".Length);
|
||||
BinaryPrimitives.ReadUInt16BigEndian(msg).ShouldBe((ushort)1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateCloseMessage_LongBody_Truncated()
|
||||
{
|
||||
var longBody = new string('x', 200);
|
||||
var msg = WsFrameWriter.CreateCloseMessage(1000, longBody);
|
||||
msg.Length.ShouldBeLessThanOrEqualTo(WsConstants.MaxControlPayloadSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCloseStatus_ClientClosed_NormalClosure()
|
||||
{
|
||||
WsFrameWriter.MapCloseStatus(ClientClosedReason.ClientClosed)
|
||||
.ShouldBe(WsConstants.CloseStatusNormalClosure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCloseStatus_AuthTimeout_PolicyViolation()
|
||||
{
|
||||
WsFrameWriter.MapCloseStatus(ClientClosedReason.AuthenticationTimeout)
|
||||
.ShouldBe(WsConstants.CloseStatusPolicyViolation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCloseStatus_ParseError_ProtocolError()
|
||||
{
|
||||
WsFrameWriter.MapCloseStatus(ClientClosedReason.ParseError)
|
||||
.ShouldBe(WsConstants.CloseStatusProtocolError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCloseStatus_MaxPayload_MessageTooBig()
|
||||
{
|
||||
WsFrameWriter.MapCloseStatus(ClientClosedReason.MaxPayloadExceeded)
|
||||
.ShouldBe(WsConstants.CloseStatusMessageTooBig);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildControlFrame_PingNomask()
|
||||
{
|
||||
var frame = WsFrameWriter.BuildControlFrame(WsConstants.PingMessage, [], useMasking: false);
|
||||
frame.Length.ShouldBe(2);
|
||||
(frame[0] & WsConstants.FinalBit).ShouldNotBe(0);
|
||||
(frame[0] & 0x0F).ShouldBe(WsConstants.PingMessage);
|
||||
(frame[1] & 0x7F).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildControlFrame_PongWithPayload()
|
||||
{
|
||||
byte[] payload = [1, 2, 3, 4];
|
||||
var frame = WsFrameWriter.BuildControlFrame(WsConstants.PongMessage, payload, useMasking: false);
|
||||
frame.Length.ShouldBe(2 + 4);
|
||||
frame[2..].ShouldBe(payload);
|
||||
}
|
||||
}
|
||||
162
tests/NATS.Server.Tests/WebSocket/WsIntegrationTests.cs
Normal file
162
tests/NATS.Server.Tests/WebSocket/WsIntegrationTests.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using NATS.Server.WebSocket;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
public class WsIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private NatsServer _server = null!;
|
||||
private NatsOptions _options = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_options = new NatsOptions
|
||||
{
|
||||
Port = 0,
|
||||
WebSocket = new WebSocketOptions { Port = 0, NoTls = true },
|
||||
};
|
||||
var loggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(b => { });
|
||||
_server = new NatsServer(_options, loggerFactory);
|
||||
_ = _server.StartAsync(CancellationToken.None);
|
||||
await _server.WaitForReadyAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _server.ShutdownAsync();
|
||||
_server.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocket_ConnectAndReceiveInfo()
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _options.WebSocket.Port));
|
||||
using var stream = new NetworkStream(socket, ownsSocket: false);
|
||||
|
||||
await SendUpgradeRequest(stream);
|
||||
var response = await ReadHttpResponse(stream);
|
||||
response.ShouldContain("101");
|
||||
|
||||
var wsFrame = await ReadWsFrame(stream);
|
||||
var info = Encoding.ASCII.GetString(wsFrame);
|
||||
info.ShouldStartWith("INFO ");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocket_ConnectAndPing()
|
||||
{
|
||||
using var client = await ConnectWsClient();
|
||||
|
||||
// Send CONNECT and PING together
|
||||
await SendWsText(client, "CONNECT {}\r\nPING\r\n");
|
||||
|
||||
// Read PONG WS frame
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var pong = await ReadWsFrameAsync(client, cts.Token);
|
||||
Encoding.ASCII.GetString(pong).ShouldContain("PONG");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocket_PubSub()
|
||||
{
|
||||
using var sub = await ConnectWsClient();
|
||||
using var pub = await ConnectWsClient();
|
||||
|
||||
await SendWsText(sub, "CONNECT {}\r\nSUB test.ws 1\r\n");
|
||||
await Task.Delay(200);
|
||||
|
||||
await SendWsText(pub, "CONNECT {}\r\nPUB test.ws 5\r\nHello\r\n");
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await ReadWsFrameAsync(sub, cts.Token);
|
||||
Encoding.ASCII.GetString(msg).ShouldContain("MSG test.ws 1 5");
|
||||
}
|
||||
|
||||
private async Task<NetworkStream> ConnectWsClient()
|
||||
{
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _options.WebSocket.Port));
|
||||
var stream = new NetworkStream(socket, ownsSocket: true);
|
||||
|
||||
await SendUpgradeRequest(stream);
|
||||
var response = await ReadHttpResponse(stream);
|
||||
response.ShouldContain("101");
|
||||
|
||||
await ReadWsFrame(stream); // Read INFO frame
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static async Task SendUpgradeRequest(NetworkStream stream)
|
||||
{
|
||||
var keyBytes = new byte[16];
|
||||
RandomNumberGenerator.Fill(keyBytes);
|
||||
var key = Convert.ToBase64String(keyBytes);
|
||||
|
||||
var request = $"GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n";
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes(request));
|
||||
await stream.FlushAsync();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadHttpResponse(NetworkStream stream)
|
||||
{
|
||||
// Read one byte at a time to avoid consuming WS frame bytes that follow the HTTP response
|
||||
var sb = new StringBuilder();
|
||||
var buf = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
int n = await stream.ReadAsync(buf);
|
||||
if (n == 0) break;
|
||||
sb.Append((char)buf[0]);
|
||||
if (sb.Length >= 4 &&
|
||||
sb[^4] == '\r' && sb[^3] == '\n' &&
|
||||
sb[^2] == '\r' && sb[^1] == '\n')
|
||||
break;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static Task<byte[]> ReadWsFrame(NetworkStream stream)
|
||||
=> ReadWsFrameAsync(stream, CancellationToken.None);
|
||||
|
||||
private static async Task<byte[]> ReadWsFrameAsync(NetworkStream stream, CancellationToken ct)
|
||||
{
|
||||
var header = new byte[2];
|
||||
await stream.ReadExactlyAsync(header, ct);
|
||||
int len = header[1] & 0x7F;
|
||||
if (len == 126)
|
||||
{
|
||||
var extLen = new byte[2];
|
||||
await stream.ReadExactlyAsync(extLen, ct);
|
||||
len = BinaryPrimitives.ReadUInt16BigEndian(extLen);
|
||||
}
|
||||
else if (len == 127)
|
||||
{
|
||||
var extLen = new byte[8];
|
||||
await stream.ReadExactlyAsync(extLen, ct);
|
||||
len = (int)BinaryPrimitives.ReadUInt64BigEndian(extLen);
|
||||
}
|
||||
|
||||
var payload = new byte[len];
|
||||
if (len > 0) await stream.ReadExactlyAsync(payload, ct);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static async Task SendWsText(NetworkStream stream, string text)
|
||||
{
|
||||
var payload = Encoding.ASCII.GetBytes(text);
|
||||
var (header, _) = WsFrameWriter.CreateFrameHeader(
|
||||
useMasking: true, compressed: false,
|
||||
opcode: WsConstants.BinaryMessage, payloadLength: payload.Length);
|
||||
var maskKey = header[^4..];
|
||||
WsFrameWriter.MaskBuf(maskKey, payload);
|
||||
await stream.WriteAsync(header);
|
||||
await stream.WriteAsync(payload);
|
||||
await stream.FlushAsync();
|
||||
}
|
||||
}
|
||||
82
tests/NATS.Server.Tests/WebSocket/WsOriginCheckerTests.cs
Normal file
82
tests/NATS.Server.Tests/WebSocket/WsOriginCheckerTests.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using NATS.Server.WebSocket;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
public class WsOriginCheckerTests
|
||||
{
|
||||
[Fact]
|
||||
public void NoOriginHeader_Accepted()
|
||||
{
|
||||
var checker = new WsOriginChecker(sameOrigin: true, allowedOrigins: null);
|
||||
checker.CheckOrigin(origin: null, requestHost: "localhost:4222", isTls: false)
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeitherSameNorList_AlwaysAccepted()
|
||||
{
|
||||
var checker = new WsOriginChecker(sameOrigin: false, allowedOrigins: null);
|
||||
checker.CheckOrigin("https://evil.com", "localhost:4222", false)
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameOrigin_Match()
|
||||
{
|
||||
var checker = new WsOriginChecker(sameOrigin: true, allowedOrigins: null);
|
||||
checker.CheckOrigin("http://localhost:4222", "localhost:4222", false)
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameOrigin_Mismatch()
|
||||
{
|
||||
var checker = new WsOriginChecker(sameOrigin: true, allowedOrigins: null);
|
||||
checker.CheckOrigin("http://other:4222", "localhost:4222", false)
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameOrigin_DefaultPort_Http()
|
||||
{
|
||||
var checker = new WsOriginChecker(sameOrigin: true, allowedOrigins: null);
|
||||
checker.CheckOrigin("http://localhost", "localhost:80", false)
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameOrigin_DefaultPort_Https()
|
||||
{
|
||||
var checker = new WsOriginChecker(sameOrigin: true, allowedOrigins: null);
|
||||
checker.CheckOrigin("https://localhost", "localhost:443", true)
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedOrigins_Match()
|
||||
{
|
||||
var checker = new WsOriginChecker(sameOrigin: false,
|
||||
allowedOrigins: ["https://app.example.com"]);
|
||||
checker.CheckOrigin("https://app.example.com", "localhost:4222", false)
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedOrigins_Mismatch()
|
||||
{
|
||||
var checker = new WsOriginChecker(sameOrigin: false,
|
||||
allowedOrigins: ["https://app.example.com"]);
|
||||
checker.CheckOrigin("https://evil.example.com", "localhost:4222", false)
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedOrigins_SchemeMismatch()
|
||||
{
|
||||
var checker = new WsOriginChecker(sameOrigin: false,
|
||||
allowedOrigins: ["https://app.example.com"]);
|
||||
checker.CheckOrigin("http://app.example.com", "localhost:4222", false)
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
226
tests/NATS.Server.Tests/WebSocket/WsUpgradeTests.cs
Normal file
226
tests/NATS.Server.Tests/WebSocket/WsUpgradeTests.cs
Normal file
@@ -0,0 +1,226 @@
|
||||
using System.Text;
|
||||
using NATS.Server.WebSocket;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
public class WsUpgradeTests
|
||||
{
|
||||
private static string BuildValidRequest(string path = "/", string? extraHeaders = 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: 13\r\n");
|
||||
if (extraHeaders != null)
|
||||
sb.Append(extraHeaders);
|
||||
sb.Append("\r\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidUpgrade_Returns101()
|
||||
{
|
||||
var request = BuildValidRequest();
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Kind.ShouldBe(WsClientKind.Client);
|
||||
var response = ReadResponse(outputStream);
|
||||
response.ShouldContain("HTTP/1.1 101");
|
||||
response.ShouldContain("Upgrade: websocket");
|
||||
response.ShouldContain("Sec-WebSocket-Accept:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingUpgradeHeader_Returns400()
|
||||
{
|
||||
var request = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n";
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
ReadResponse(outputStream).ShouldContain("400");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingHost_Returns400()
|
||||
{
|
||||
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 (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongVersion_Returns400()
|
||||
{
|
||||
var request = "GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 12\r\n\r\n";
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LeafNodePath_ReturnsLeafKind()
|
||||
{
|
||||
var request = BuildValidRequest("/leafnode");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Kind.ShouldBe(WsClientKind.Leaf);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MqttPath_ReturnsMqttKind()
|
||||
{
|
||||
var request = BuildValidRequest("/mqtt");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Kind.ShouldBe(WsClientKind.Mqtt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompressionNegotiation_WhenEnabled()
|
||||
{
|
||||
var request = BuildValidRequest(extraHeaders: $"Sec-WebSocket-Extensions: {WsConstants.PmcExtension}; {WsConstants.PmcSrvNoCtx}; {WsConstants.PmcCliNoCtx}\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true, Compression = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Compress.ShouldBeTrue();
|
||||
ReadResponse(outputStream).ShouldContain("permessage-deflate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompressionNegotiation_WhenDisabled()
|
||||
{
|
||||
var request = BuildValidRequest(extraHeaders: $"Sec-WebSocket-Extensions: {WsConstants.PmcExtension}\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true, Compression = false });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Compress.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoMaskingHeader_ForLeaf()
|
||||
{
|
||||
var request = BuildValidRequest("/leafnode", "Nats-No-Masking: true\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.MaskRead.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowserDetection_Mozilla()
|
||||
{
|
||||
var request = BuildValidRequest(extraHeaders: "User-Agent: Mozilla/5.0 (Windows)\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Browser.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SafariDetection_NoCompFrag()
|
||||
{
|
||||
var request = BuildValidRequest(extraHeaders:
|
||||
"User-Agent: Mozilla/5.0 (Macintosh) Version/15.0 Safari/605.1.15\r\n" +
|
||||
$"Sec-WebSocket-Extensions: {WsConstants.PmcExtension}\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true, Compression = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.NoCompFrag.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcceptKey_MatchesRfc6455Example()
|
||||
{
|
||||
// RFC 6455 Section 4.2.2 example
|
||||
var key = WsUpgrade.ComputeAcceptKey("dGhlIHNhbXBsZSBub25jZQ==");
|
||||
key.ShouldBe("s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CookieExtraction()
|
||||
{
|
||||
var request = BuildValidRequest(extraHeaders:
|
||||
"Cookie: jwt_token=my-jwt; nats_user=admin; nats_pass=secret\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions
|
||||
{
|
||||
NoTls = true,
|
||||
JwtCookie = "jwt_token",
|
||||
UsernameCookie = "nats_user",
|
||||
PasswordCookie = "nats_pass",
|
||||
};
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.CookieJwt.ShouldBe("my-jwt");
|
||||
result.CookieUsername.ShouldBe("admin");
|
||||
result.CookiePassword.ShouldBe("secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task XForwardedFor_ExtractsClientIp()
|
||||
{
|
||||
var request = BuildValidRequest(extraHeaders: "X-Forwarded-For: 192.168.1.100\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.ClientIp.ShouldBe("192.168.1.100");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostMethod_Returns405()
|
||||
{
|
||||
var request = "POST / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n";
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
ReadResponse(outputStream).ShouldContain("405");
|
||||
}
|
||||
|
||||
// Helper: create a readable input stream and writable output stream
|
||||
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