Move TLS, OCSP, WebSocket, Networking, and IO test files from NATS.Server.Tests into a dedicated NATS.Server.Transport.Tests project. Update namespaces, replace private GetFreePort/ReadUntilAsync with shared TestUtilities helpers, extract TestCertHelper to TestUtilities, and replace Task.Delay polling loops with PollHelper.WaitUntilAsync/YieldForAsync for proper synchronization.
328 lines
12 KiB
C#
328 lines
12 KiB
C#
// Tests for WebSocket permessage-deflate parameter negotiation (E10).
|
|
// Verifies RFC 7692 extension parameter parsing and negotiation during
|
|
// WebSocket upgrade handshake.
|
|
// Reference: golang/nats-server/server/websocket.go — wsPMCExtensionSupport (line 885).
|
|
|
|
using System.Text;
|
|
using NATS.Server.WebSocket;
|
|
|
|
namespace NATS.Server.Transport.Tests.WebSocket;
|
|
|
|
public class WsCompressionNegotiationTests
|
|
{
|
|
// ─── WsDeflateNegotiator.Negotiate tests ──────────────────────────────
|
|
|
|
[Fact]
|
|
public void Negotiate_NullHeader_ReturnsNull()
|
|
{
|
|
// Go parity: wsPMCExtensionSupport — no extension header means no compression
|
|
var result = WsDeflateNegotiator.Negotiate(null);
|
|
result.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_EmptyHeader_ReturnsNull()
|
|
{
|
|
var result = WsDeflateNegotiator.Negotiate("");
|
|
result.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_NoPermessageDeflate_ReturnsNull()
|
|
{
|
|
var result = WsDeflateNegotiator.Negotiate("x-webkit-deflate-frame");
|
|
result.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_BarePermessageDeflate_ReturnsDefaults()
|
|
{
|
|
// Go parity: wsPMCExtensionSupport — basic extension without parameters
|
|
var result = WsDeflateNegotiator.Negotiate("permessage-deflate");
|
|
|
|
result.ShouldNotBeNull();
|
|
// NATS always enforces no_context_takeover
|
|
result.Value.ServerNoContextTakeover.ShouldBeTrue();
|
|
result.Value.ClientNoContextTakeover.ShouldBeTrue();
|
|
result.Value.ServerMaxWindowBits.ShouldBe(15);
|
|
result.Value.ClientMaxWindowBits.ShouldBe(15);
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_WithServerNoContextTakeover()
|
|
{
|
|
// Go parity: wsPMCExtensionSupport — server_no_context_takeover parameter
|
|
var result = WsDeflateNegotiator.Negotiate("permessage-deflate; server_no_context_takeover");
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Value.ServerNoContextTakeover.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_WithClientNoContextTakeover()
|
|
{
|
|
// Go parity: wsPMCExtensionSupport — client_no_context_takeover parameter
|
|
var result = WsDeflateNegotiator.Negotiate("permessage-deflate; client_no_context_takeover");
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Value.ClientNoContextTakeover.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_WithBothNoContextTakeover()
|
|
{
|
|
// Go parity: wsPMCExtensionSupport — both no_context_takeover parameters
|
|
var result = WsDeflateNegotiator.Negotiate(
|
|
"permessage-deflate; server_no_context_takeover; client_no_context_takeover");
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Value.ServerNoContextTakeover.ShouldBeTrue();
|
|
result.Value.ClientNoContextTakeover.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_WithServerMaxWindowBits()
|
|
{
|
|
// RFC 7692 Section 7.1.2.1: server_max_window_bits parameter
|
|
var result = WsDeflateNegotiator.Negotiate("permessage-deflate; server_max_window_bits=10");
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Value.ServerMaxWindowBits.ShouldBe(10);
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_WithClientMaxWindowBits_Value()
|
|
{
|
|
// RFC 7692 Section 7.1.2.2: client_max_window_bits with explicit value
|
|
var result = WsDeflateNegotiator.Negotiate("permessage-deflate; client_max_window_bits=12");
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Value.ClientMaxWindowBits.ShouldBe(12);
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_WithClientMaxWindowBits_NoValue()
|
|
{
|
|
// RFC 7692 Section 7.1.2.2: client_max_window_bits with no value means
|
|
// client supports any value 8-15; defaults to 15
|
|
var result = WsDeflateNegotiator.Negotiate("permessage-deflate; client_max_window_bits");
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Value.ClientMaxWindowBits.ShouldBe(15);
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_WindowBits_ClampedToValidRange()
|
|
{
|
|
// RFC 7692: valid range is 8-15
|
|
var result = WsDeflateNegotiator.Negotiate(
|
|
"permessage-deflate; server_max_window_bits=5; client_max_window_bits=20");
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Value.ServerMaxWindowBits.ShouldBe(8); // Clamped up from 5
|
|
result.Value.ClientMaxWindowBits.ShouldBe(15); // Clamped down from 20
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_FullParameters()
|
|
{
|
|
// All parameters specified
|
|
var result = WsDeflateNegotiator.Negotiate(
|
|
"permessage-deflate; server_no_context_takeover; client_no_context_takeover; server_max_window_bits=9; client_max_window_bits=11");
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Value.ServerNoContextTakeover.ShouldBeTrue();
|
|
result.Value.ClientNoContextTakeover.ShouldBeTrue();
|
|
result.Value.ServerMaxWindowBits.ShouldBe(9);
|
|
result.Value.ClientMaxWindowBits.ShouldBe(11);
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_CaseInsensitive()
|
|
{
|
|
// RFC 7692 extension names are case-insensitive
|
|
var result = WsDeflateNegotiator.Negotiate("Permessage-Deflate; Server_No_Context_Takeover");
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Value.ServerNoContextTakeover.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_MultipleExtensions_PicksDeflate()
|
|
{
|
|
// Header may contain multiple comma-separated extensions
|
|
var result = WsDeflateNegotiator.Negotiate(
|
|
"x-custom-ext, permessage-deflate; server_no_context_takeover, other-ext");
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Value.ServerNoContextTakeover.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Negotiate_WhitespaceHandling()
|
|
{
|
|
// Extra whitespace around parameters
|
|
var result = WsDeflateNegotiator.Negotiate(
|
|
" permessage-deflate ; server_no_context_takeover ; client_max_window_bits = 10 ");
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Value.ServerNoContextTakeover.ShouldBeTrue();
|
|
result.Value.ClientMaxWindowBits.ShouldBe(10);
|
|
}
|
|
|
|
// ─── NatsAlwaysEnforcesNoContextTakeover ─────────────────────────────
|
|
|
|
[Fact]
|
|
public void Negotiate_AlwaysEnforcesNoContextTakeover()
|
|
{
|
|
// NATS Go server always returns server_no_context_takeover and
|
|
// client_no_context_takeover regardless of what the client requests
|
|
var result = WsDeflateNegotiator.Negotiate("permessage-deflate");
|
|
|
|
result.ShouldNotBeNull();
|
|
result.Value.ServerNoContextTakeover.ShouldBeTrue();
|
|
result.Value.ClientNoContextTakeover.ShouldBeTrue();
|
|
}
|
|
|
|
// ─── WsDeflateParams.ToResponseHeaderValue tests ────────────────────
|
|
|
|
[Fact]
|
|
public void DefaultParams_ResponseHeader_ContainsNoContextTakeover()
|
|
{
|
|
var header = WsDeflateParams.Default.ToResponseHeaderValue();
|
|
|
|
header.ShouldContain("permessage-deflate");
|
|
header.ShouldContain("server_no_context_takeover");
|
|
header.ShouldContain("client_no_context_takeover");
|
|
header.ShouldNotContain("server_max_window_bits");
|
|
header.ShouldNotContain("client_max_window_bits");
|
|
}
|
|
|
|
[Fact]
|
|
public void CustomWindowBits_ResponseHeader_IncludesValues()
|
|
{
|
|
var params_ = new WsDeflateParams(
|
|
ServerNoContextTakeover: true,
|
|
ClientNoContextTakeover: true,
|
|
ServerMaxWindowBits: 10,
|
|
ClientMaxWindowBits: 12);
|
|
|
|
var header = params_.ToResponseHeaderValue();
|
|
|
|
header.ShouldContain("server_max_window_bits=10");
|
|
header.ShouldContain("client_max_window_bits=12");
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultWindowBits_ResponseHeader_OmitsValues()
|
|
{
|
|
// RFC 7692: window bits of 15 is the default and should not be sent
|
|
var params_ = new WsDeflateParams(
|
|
ServerNoContextTakeover: true,
|
|
ClientNoContextTakeover: true,
|
|
ServerMaxWindowBits: 15,
|
|
ClientMaxWindowBits: 15);
|
|
|
|
var header = params_.ToResponseHeaderValue();
|
|
|
|
header.ShouldNotContain("server_max_window_bits");
|
|
header.ShouldNotContain("client_max_window_bits");
|
|
}
|
|
|
|
// ─── Integration with WsUpgrade ─────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Upgrade_WithDeflateParams_NegotiatesCompression()
|
|
{
|
|
// Go parity: WebSocket upgrade with permessage-deflate parameters
|
|
var request = BuildValidRequest(extraHeaders:
|
|
"Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover; server_max_window_bits=10\r\n");
|
|
var (inputStream, outputStream) = CreateStreamPair(request);
|
|
|
|
var opts = new WebSocketOptions { NoTls = true, Compression = true };
|
|
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, opts);
|
|
|
|
result.Success.ShouldBeTrue();
|
|
result.Compress.ShouldBeTrue();
|
|
result.DeflateParams.ShouldNotBeNull();
|
|
result.DeflateParams.Value.ServerNoContextTakeover.ShouldBeTrue();
|
|
result.DeflateParams.Value.ClientNoContextTakeover.ShouldBeTrue();
|
|
result.DeflateParams.Value.ServerMaxWindowBits.ShouldBe(10);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Upgrade_WithDeflateParams_ResponseIncludesNegotiatedParams()
|
|
{
|
|
var request = BuildValidRequest(extraHeaders:
|
|
"Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_max_window_bits=10\r\n");
|
|
var (inputStream, outputStream) = CreateStreamPair(request);
|
|
|
|
var opts = new WebSocketOptions { NoTls = true, Compression = true };
|
|
await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, opts);
|
|
|
|
var response = ReadResponse(outputStream);
|
|
response.ShouldContain("permessage-deflate");
|
|
response.ShouldContain("server_no_context_takeover");
|
|
response.ShouldContain("client_no_context_takeover");
|
|
response.ShouldContain("client_max_window_bits=10");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Upgrade_CompressionDisabled_NoDeflateParams()
|
|
{
|
|
var request = BuildValidRequest(extraHeaders:
|
|
"Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover\r\n");
|
|
var (inputStream, outputStream) = CreateStreamPair(request);
|
|
|
|
var opts = new WebSocketOptions { NoTls = true, Compression = false };
|
|
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, opts);
|
|
|
|
result.Success.ShouldBeTrue();
|
|
result.Compress.ShouldBeFalse();
|
|
result.DeflateParams.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Upgrade_NoExtensionHeader_NoCompression()
|
|
{
|
|
var request = BuildValidRequest();
|
|
var (inputStream, outputStream) = CreateStreamPair(request);
|
|
|
|
var opts = new WebSocketOptions { NoTls = true, Compression = true };
|
|
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, opts);
|
|
|
|
result.Success.ShouldBeTrue();
|
|
result.Compress.ShouldBeFalse();
|
|
result.DeflateParams.ShouldBeNull();
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
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();
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|