Fix E2E test gaps and add comprehensive E2E + parity test suites
- Fix pull consumer fetch: send original stream subject in HMSG (not inbox) so NATS client distinguishes data messages from control messages - Fix MaxAge expiry: add background timer in StreamManager for periodic pruning - Fix JetStream wire format: Go-compatible anonymous objects with string enums, proper offset-based pagination for stream/consumer list APIs - Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream) - Add ~1000 parity tests across all subsystems (gaps closure) - Update gap inventory docs to reflect implementation status
This commit is contained in:
93
src/NATS.Server/WebSocket/WebSocketOptionsValidator.cs
Normal file
93
src/NATS.Server/WebSocket/WebSocketOptionsValidator.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
namespace NATS.Server.WebSocket;
|
||||
|
||||
/// <summary>
|
||||
/// Validates websocket options against server-wide auth/TLS/operator settings.
|
||||
/// Go reference: websocket.go validateWebsocketOptions.
|
||||
/// </summary>
|
||||
public static class WebSocketOptionsValidator
|
||||
{
|
||||
private static readonly HashSet<string> ReservedResponseHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Upgrade",
|
||||
"Connection",
|
||||
"Sec-WebSocket-Accept",
|
||||
"Sec-WebSocket-Extensions",
|
||||
"Sec-WebSocket-Protocol",
|
||||
};
|
||||
|
||||
public static WebSocketOptionsValidationResult Validate(NatsOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var ws = options.WebSocket;
|
||||
var errors = new List<string>();
|
||||
|
||||
if (ws.Port < 0)
|
||||
return new WebSocketOptionsValidationResult(true, errors);
|
||||
|
||||
if (!ws.NoTls)
|
||||
{
|
||||
var hasCert = !string.IsNullOrWhiteSpace(ws.TlsCert);
|
||||
var hasKey = !string.IsNullOrWhiteSpace(ws.TlsKey);
|
||||
if (!hasCert || !hasKey)
|
||||
errors.Add("WebSocket TLS listener requires both TlsCert and TlsKey when NoTls is false.");
|
||||
}
|
||||
|
||||
if (ws.AllowedOrigins is { Count: > 0 })
|
||||
{
|
||||
foreach (var origin in ws.AllowedOrigins)
|
||||
{
|
||||
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
errors.Add($"Invalid websocket allowed origin: '{origin}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ws.NoAuthUser) && options.Users is { Count: > 0 })
|
||||
{
|
||||
var match = options.Users.Any(u => string.Equals(u.Username, ws.NoAuthUser, StringComparison.Ordinal));
|
||||
if (!match)
|
||||
errors.Add("WebSocket NoAuthUser must match one of the configured users.");
|
||||
}
|
||||
|
||||
if ((!string.IsNullOrWhiteSpace(ws.Username) || !string.IsNullOrWhiteSpace(ws.Token))
|
||||
&& ((options.Users?.Count ?? 0) > 0 || (options.NKeys?.Count ?? 0) > 0))
|
||||
{
|
||||
errors.Add("WebSocket Username/Token cannot be set when users or nkeys are configured.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ws.JwtCookie) && (options.TrustedKeys == null || options.TrustedKeys.Length == 0))
|
||||
{
|
||||
errors.Add("WebSocket JwtCookie requires trusted operators (TrustedKeys).");
|
||||
}
|
||||
|
||||
if (options.TlsPinnedCerts is { Count: > 0 })
|
||||
{
|
||||
if (ws.NoTls)
|
||||
errors.Add("WebSocket TLSPinnedCerts require TLS (NoTls must be false).");
|
||||
|
||||
foreach (var pin in options.TlsPinnedCerts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pin) || !pin.All(Uri.IsHexDigit))
|
||||
errors.Add($"Invalid websocket pinned cert hash: '{pin}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (ws.Headers is { Count: > 0 })
|
||||
{
|
||||
foreach (var headerName in ws.Headers.Keys)
|
||||
{
|
||||
if (ReservedResponseHeaders.Contains(headerName))
|
||||
errors.Add($"WebSocket header '{headerName}' is reserved and cannot be overridden.");
|
||||
}
|
||||
}
|
||||
|
||||
return new WebSocketOptionsValidationResult(errors.Count == 0, errors);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WebSocketOptionsValidationResult(
|
||||
bool IsValid,
|
||||
IReadOnlyList<string> Errors);
|
||||
19
src/NATS.Server/WebSocket/WsAuthConfig.cs
Normal file
19
src/NATS.Server/WebSocket/WsAuthConfig.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace NATS.Server.WebSocket;
|
||||
|
||||
public static class WsAuthConfig
|
||||
{
|
||||
public static bool ComputeAuthOverride(WebSocketOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return !string.IsNullOrWhiteSpace(options.Username)
|
||||
|| !string.IsNullOrWhiteSpace(options.Token)
|
||||
|| !string.IsNullOrWhiteSpace(options.NoAuthUser);
|
||||
}
|
||||
|
||||
public static void Apply(WebSocketOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
options.AuthOverride = ComputeAuthOverride(options);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ namespace NATS.Server.WebSocket;
|
||||
/// </summary>
|
||||
public static class WsUpgrade
|
||||
{
|
||||
// Go test hook parity: when true, force rejection of no-masking requests.
|
||||
public static bool RejectNoMaskingForTest { get; set; }
|
||||
|
||||
public static async Task<WsUpgradeResult> TryUpgradeAsync(
|
||||
Stream inputStream, Stream outputStream, WebSocketOptions options,
|
||||
CancellationToken ct = default)
|
||||
@@ -72,6 +75,9 @@ public static class WsUpgrade
|
||||
headers.TryGetValue(WsConstants.NoMaskingHeader, out var nmVal) &&
|
||||
string.Equals(nmVal.Trim(), WsConstants.NoMaskingValue, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (noMasking && RejectNoMaskingForTest)
|
||||
return await FailAsync(outputStream, 400, "invalid value for no-masking");
|
||||
|
||||
// Browser detection
|
||||
bool browser = false;
|
||||
bool noCompFrag = false;
|
||||
@@ -179,6 +185,41 @@ public static class WsUpgrade
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a random base64-encoded 16-byte websocket challenge key.
|
||||
/// Go reference: wsMakeChallengeKey().
|
||||
/// </summary>
|
||||
public static string MakeChallengeKey()
|
||||
{
|
||||
Span<byte> nonce = stackalloc byte[16];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
return Convert.ToBase64String(nonce);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the URL uses the ws:// scheme.
|
||||
/// Go reference: isWSURL().
|
||||
/// </summary>
|
||||
public static bool IsWsUrl(string? url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
return string.Equals(uri.Scheme, "ws", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the URL uses the wss:// scheme.
|
||||
/// Go reference: isWSSURL().
|
||||
/// </summary>
|
||||
public static bool IsWssUrl(string? url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
return string.Equals(uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a bearer token from an Authorization header value.
|
||||
/// Supports both "Bearer {token}" and bare "{token}" formats.
|
||||
|
||||
Reference in New Issue
Block a user