feat(config+ws): add TLS cert reload, WS compression negotiation, WS JWT auth (E9+E10+E11)
E9: TLS Certificate Reload - Add TlsCertificateProvider with Interlocked-swappable cert field - New connections get current cert, existing connections keep theirs - ConfigReloader.ReloadTlsCertificate rebuilds SslServerAuthenticationOptions - NatsServer.ApplyConfigChanges triggers TLS reload on TLS config changes - 11 tests covering cert swap, versioning, thread safety, config diff E10: WebSocket Compression Negotiation (RFC 7692) - Add WsDeflateNegotiator to parse Sec-WebSocket-Extensions parameters - Parse server_no_context_takeover, client_no_context_takeover, server_max_window_bits, client_max_window_bits - WsDeflateParams record struct with ToResponseHeaderValue() - NATS always enforces no_context_takeover (matching Go server) - WsUpgrade returns negotiated WsDeflateParams in upgrade result - 22 tests covering parameter parsing, clamping, response headers E11: WebSocket JWT Authentication - Extract JWT from Authorization header (Bearer token), cookie, or ?jwt= query param - Priority: Authorization header > cookie > query parameter - WsUpgrade.TryUpgradeAsync now parses query string from request URI - Add FailUnauthorizedAsync for 401 responses - 24 tests covering all JWT extraction sources and priority ordering
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
// 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.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());
|
||||
}
|
||||
}
|
||||
316
tests/NATS.Server.Tests/WebSocket/WsJwtAuthTests.cs
Normal file
316
tests/NATS.Server.Tests/WebSocket/WsJwtAuthTests.cs
Normal file
@@ -0,0 +1,316 @@
|
||||
// Tests for WebSocket JWT authentication during upgrade (E11).
|
||||
// Verifies JWT extraction from Authorization header, cookie, and query parameter.
|
||||
// Reference: golang/nats-server/server/websocket.go — cookie JWT extraction (line 856),
|
||||
// websocket_test.go — TestWSReloadTLSConfig (line 4066).
|
||||
|
||||
using System.Text;
|
||||
using NATS.Server.WebSocket;
|
||||
|
||||
namespace NATS.Server.Tests.WebSocket;
|
||||
|
||||
public class WsJwtAuthTests
|
||||
{
|
||||
// ─── Authorization header JWT extraction ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_AuthorizationBearerHeader_ExtractsJwt()
|
||||
{
|
||||
// JWT from Authorization: Bearer <token> header (standard HTTP auth)
|
||||
var jwt = "eyJhbGciOiJFZDI1NTE5IiwidHlwIjoiSldUIn0.test-payload.test-sig";
|
||||
var request = BuildValidRequest(extraHeaders:
|
||||
$"Authorization: Bearer {jwt}\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Jwt.ShouldBe(jwt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_AuthorizationBearerCaseInsensitive()
|
||||
{
|
||||
// RFC 7235: "bearer" scheme is case-insensitive
|
||||
var jwt = "my-jwt-token-123";
|
||||
var request = BuildValidRequest(extraHeaders:
|
||||
$"Authorization: bearer {jwt}\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Jwt.ShouldBe(jwt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_AuthorizationBareToken_ExtractsJwt()
|
||||
{
|
||||
// Some clients send the token directly without "Bearer" prefix
|
||||
var jwt = "raw-jwt-token-456";
|
||||
var request = BuildValidRequest(extraHeaders:
|
||||
$"Authorization: {jwt}\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Jwt.ShouldBe(jwt);
|
||||
}
|
||||
|
||||
// ─── Cookie JWT extraction ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_JwtCookie_ExtractsJwt()
|
||||
{
|
||||
// Go parity: websocket.go line 856 — JWT from configured cookie name
|
||||
var jwt = "cookie-jwt-token-789";
|
||||
var request = BuildValidRequest(extraHeaders:
|
||||
$"Cookie: jwt={jwt}; other=value\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true, JwtCookie = "jwt" };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.CookieJwt.ShouldBe(jwt);
|
||||
// Cookie JWT is used as fallback when no Authorization header is present
|
||||
result.Jwt.ShouldBe(jwt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_AuthorizationHeader_TakesPriorityOverCookie()
|
||||
{
|
||||
// Authorization header has higher priority than cookie
|
||||
var headerJwt = "auth-header-jwt";
|
||||
var cookieJwt = "cookie-jwt";
|
||||
var request = BuildValidRequest(extraHeaders:
|
||||
$"Authorization: Bearer {headerJwt}\r\n" +
|
||||
$"Cookie: jwt={cookieJwt}\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true, JwtCookie = "jwt" };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Jwt.ShouldBe(headerJwt);
|
||||
result.CookieJwt.ShouldBe(cookieJwt); // Cookie value is still preserved
|
||||
}
|
||||
|
||||
// ─── Query parameter JWT extraction ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_QueryParamJwt_ExtractsJwt()
|
||||
{
|
||||
// JWT from ?jwt= query parameter (useful for browser clients)
|
||||
var jwt = "query-jwt-token-abc";
|
||||
var request = BuildValidRequest(path: $"/?jwt={jwt}");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Jwt.ShouldBe(jwt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_QueryParamJwt_UrlEncoded()
|
||||
{
|
||||
// JWT value may be URL-encoded
|
||||
var jwt = "eyJ0eXAiOiJKV1QifQ.payload.sig";
|
||||
var encoded = Uri.EscapeDataString(jwt);
|
||||
var request = BuildValidRequest(path: $"/?jwt={encoded}");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Jwt.ShouldBe(jwt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_AuthorizationHeader_TakesPriorityOverQueryParam()
|
||||
{
|
||||
// Authorization header > query parameter
|
||||
var headerJwt = "auth-header-jwt";
|
||||
var queryJwt = "query-jwt";
|
||||
var request = BuildValidRequest(
|
||||
path: $"/?jwt={queryJwt}",
|
||||
extraHeaders: $"Authorization: Bearer {headerJwt}\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Jwt.ShouldBe(headerJwt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_Cookie_TakesPriorityOverQueryParam()
|
||||
{
|
||||
// Cookie > query parameter
|
||||
var cookieJwt = "cookie-jwt";
|
||||
var queryJwt = "query-jwt";
|
||||
var request = BuildValidRequest(
|
||||
path: $"/?jwt={queryJwt}",
|
||||
extraHeaders: $"Cookie: jwt_token={cookieJwt}\r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var opts = new WebSocketOptions { NoTls = true, JwtCookie = "jwt_token" };
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, opts);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Jwt.ShouldBe(cookieJwt);
|
||||
}
|
||||
|
||||
// ─── No JWT scenarios ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_NoJwtAnywhere_JwtIsNull()
|
||||
{
|
||||
// No JWT in any source
|
||||
var request = BuildValidRequest();
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Jwt.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_EmptyAuthorizationHeader_JwtIsEmpty()
|
||||
{
|
||||
// Empty authorization header should produce empty string (non-null)
|
||||
var request = BuildValidRequest(extraHeaders: "Authorization: \r\n");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
// Empty auth header is treated as null/no JWT
|
||||
result.Jwt.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ─── ExtractBearerToken unit tests ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ExtractBearerToken_BearerPrefix()
|
||||
{
|
||||
WsUpgrade.ExtractBearerToken("Bearer my-token").ShouldBe("my-token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBearerToken_BearerPrefixLowerCase()
|
||||
{
|
||||
WsUpgrade.ExtractBearerToken("bearer my-token").ShouldBe("my-token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBearerToken_BareToken()
|
||||
{
|
||||
WsUpgrade.ExtractBearerToken("raw-token").ShouldBe("raw-token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBearerToken_Null()
|
||||
{
|
||||
WsUpgrade.ExtractBearerToken(null).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBearerToken_Empty()
|
||||
{
|
||||
WsUpgrade.ExtractBearerToken("").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBearerToken_Whitespace()
|
||||
{
|
||||
WsUpgrade.ExtractBearerToken(" ").ShouldBeNull();
|
||||
}
|
||||
|
||||
// ─── ParseQueryString unit tests ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseQueryString_SingleParam()
|
||||
{
|
||||
var result = WsUpgrade.ParseQueryString("?jwt=token123");
|
||||
result["jwt"].ShouldBe("token123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseQueryString_MultipleParams()
|
||||
{
|
||||
var result = WsUpgrade.ParseQueryString("?jwt=token&user=admin");
|
||||
result["jwt"].ShouldBe("token");
|
||||
result["user"].ShouldBe("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseQueryString_UrlEncoded()
|
||||
{
|
||||
var result = WsUpgrade.ParseQueryString("?jwt=a%20b%3Dc");
|
||||
result["jwt"].ShouldBe("a b=c");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseQueryString_NoQuestionMark()
|
||||
{
|
||||
var result = WsUpgrade.ParseQueryString("jwt=token");
|
||||
result["jwt"].ShouldBe("token");
|
||||
}
|
||||
|
||||
// ─── FailUnauthorizedAsync ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task FailUnauthorizedAsync_Returns401()
|
||||
{
|
||||
var output = new MemoryStream();
|
||||
var result = await WsUpgrade.FailUnauthorizedAsync(output, "invalid JWT");
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
output.Position = 0;
|
||||
var response = Encoding.ASCII.GetString(output.ToArray());
|
||||
response.ShouldContain("401");
|
||||
response.ShouldContain("invalid JWT");
|
||||
}
|
||||
|
||||
// ─── Query param path routing still works with query strings ────────
|
||||
|
||||
[Fact]
|
||||
public async Task Upgrade_PathWithQueryParam_StillRoutesCorrectly()
|
||||
{
|
||||
// /leafnode?jwt=token should still detect as leaf kind
|
||||
var request = BuildValidRequest(path: "/leafnode?jwt=my-token");
|
||||
var (inputStream, outputStream) = CreateStreamPair(request);
|
||||
|
||||
var result = await WsUpgrade.TryUpgradeAsync(inputStream, outputStream, new WebSocketOptions { NoTls = true });
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Kind.ShouldBe(WsClientKind.Leaf);
|
||||
result.Jwt.ShouldBe("my-token");
|
||||
}
|
||||
|
||||
// ─── 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user