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:
Joseph Doherty
2026-03-12 14:09:23 -04:00
parent 79c1ee8776
commit c30e67a69d
226 changed files with 17801 additions and 709 deletions

View 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);

View 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);
}
}

View File

@@ -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.