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

@@ -16,6 +16,7 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
private Task? _loopTask;
public string? RemoteId { get; private set; }
public bool IsOutbound { get; internal set; }
public string RemoteEndpoint => socket.RemoteEndPoint?.ToString() ?? Guid.NewGuid().ToString("N");
public Func<RemoteSubscription, Task>? RemoteSubscriptionReceived { get; set; }
public Func<GatewayMessage, Task>? MessageReceived { get; set; }

View File

@@ -65,6 +65,9 @@ public sealed class GatewayReconnectPolicy
public sealed class GatewayManager : IAsyncDisposable
{
public const string GatewayTlsInsecureWarning =
"Gateway TLS insecure configuration is enabled; verify certificates and hostname validation for production.";
private readonly GatewayOptions _options;
private readonly ServerStats _stats;
private readonly string _serverId;
@@ -103,6 +106,43 @@ public sealed class GatewayManager : IAsyncDisposable
_logger = logger;
}
/// <summary>
/// Validates gateway options for required fields and basic endpoint correctness.
/// Go reference: validateGatewayOptions.
/// </summary>
public static bool ValidateGatewayOptions(GatewayOptions? options, out string? error)
{
if (options is null)
{
error = "Gateway options are required.";
return false;
}
if (string.IsNullOrWhiteSpace(options.Name))
{
error = "Gateway name is required.";
return false;
}
if (options.Port is < 0 or > 65535)
{
error = "Gateway port must be in range 0-65535.";
return false;
}
foreach (var remote in options.Remotes)
{
if (string.IsNullOrWhiteSpace(remote))
{
error = "Gateway remote entries cannot be empty.";
return false;
}
}
error = null;
return true;
}
/// <summary>
/// Gateway clusters auto-discovered via INFO gossip.
/// Go reference: server/gateway.go processImplicitGateway.
@@ -284,6 +324,48 @@ public sealed class GatewayManager : IAsyncDisposable
public int GetConnectedGatewayCount()
=> _registrations.Values.Count(r => r.State == GatewayConnectionState.Connected);
/// <summary>
/// Returns the number of active outbound gateway connections.
/// Go reference: server/gateway.go NumOutboundGateways / numOutboundGateways.
/// </summary>
public int NumOutboundGateways()
=> _connections.Values.Count(c => c.IsOutbound);
/// <summary>
/// Returns the number of active inbound gateway connections.
/// Go reference: server/gateway.go numInboundGateways.
/// </summary>
public int NumInboundGateways()
=> _connections.Values.Count(c => !c.IsOutbound);
/// <summary>
/// Returns true if an inbound gateway connection exists for the given remote server id.
/// Go reference: server/gateway.go srvGateway.hasInbound.
/// </summary>
public bool HasInbound(string remoteServerId)
=> _connections.Values.Any(c => !c.IsOutbound && string.Equals(c.RemoteId, remoteServerId, StringComparison.Ordinal));
/// <summary>
/// Returns the first outbound gateway connection for the given remote server id, or null.
/// Go reference: server/gateway.go getOutboundGatewayConnection.
/// </summary>
public GatewayConnection? GetOutboundGatewayConnection(string remoteServerId)
=> _connections.Values.FirstOrDefault(c => c.IsOutbound && string.Equals(c.RemoteId, remoteServerId, StringComparison.Ordinal));
/// <summary>
/// Returns all outbound gateway connections.
/// Go reference: server/gateway.go getOutboundGatewayConnections.
/// </summary>
public IReadOnlyList<GatewayConnection> GetOutboundGatewayConnections()
=> _connections.Values.Where(c => c.IsOutbound).ToList();
/// <summary>
/// Returns all inbound gateway connections.
/// Go reference: server/gateway.go getInboundGatewayConnections.
/// </summary>
public IReadOnlyList<GatewayConnection> GetInboundGatewayConnections()
=> _connections.Values.Where(c => !c.IsOutbound).ToList();
/// <summary>
/// Atomically increments the messages-sent counter for the named gateway.
/// Go reference: server/gateway.go outboundGateway.msgs.
@@ -364,7 +446,7 @@ public sealed class GatewayManager : IAsyncDisposable
var endPoint = ParseEndpoint(remote);
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(endPoint.Address, endPoint.Port, ct);
var connection = new GatewayConnection(socket);
var connection = new GatewayConnection(socket) { IsOutbound = true };
await connection.PerformOutboundHandshakeAsync(_serverId, ct);
Register(connection);
return;

View File

@@ -9,14 +9,42 @@ namespace NATS.Server.Gateways;
/// </summary>
public static class ReplyMapper
{
private const string GatewayReplyPrefix = "_GR_.";
public const string GatewayReplyPrefix = "_GR_.";
public const string OldGatewayReplyPrefix = "$GR.";
public const int GatewayReplyPrefixLen = 5;
public const int OldGatewayReplyPrefixLen = 4;
public const int GatewayHashLen = 6;
public const int OldGatewayHashLen = 4;
/// <summary>
/// Checks whether the subject starts with the gateway reply prefix <c>_GR_.</c>.
/// Checks whether the subject starts with either gateway reply prefix:
/// <c>_GR_.</c> (current) or <c>$GR.</c> (legacy).
/// </summary>
public static bool HasGatewayReplyPrefix(string? subject)
=> !string.IsNullOrWhiteSpace(subject)
&& subject.StartsWith(GatewayReplyPrefix, StringComparison.Ordinal);
=> IsGatewayRoutedSubject(subject, out _);
/// <summary>
/// Returns true when the subject is gateway-routed and indicates if the legacy
/// old prefix (<c>$GR.</c>) was used.
/// Go reference: isGWRoutedSubjectAndIsOldPrefix.
/// </summary>
public static bool IsGatewayRoutedSubject(string? subject, out bool isOldPrefix)
{
isOldPrefix = false;
if (string.IsNullOrWhiteSpace(subject))
return false;
if (subject.StartsWith(GatewayReplyPrefix, StringComparison.Ordinal))
return true;
if (subject.StartsWith(OldGatewayReplyPrefix, StringComparison.Ordinal))
{
isOldPrefix = true;
return true;
}
return false;
}
/// <summary>
/// Computes a deterministic FNV-1a hash of the reply subject.
@@ -40,6 +68,26 @@ public static class ReplyMapper
return (long)(hash & 0x7FFFFFFFFFFFFFFF);
}
/// <summary>
/// Computes the short (6-char) gateway hash used in modern gateway reply routing.
/// Go reference: getGWHash.
/// </summary>
public static string ComputeGatewayHash(string gatewayName)
{
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(gatewayName));
return Convert.ToHexString(digest.AsSpan(0, 3)).ToLowerInvariant();
}
/// <summary>
/// Computes the short (4-char) legacy gateway hash used with old prefixes.
/// Go reference: getOldHash.
/// </summary>
public static string ComputeOldGatewayHash(string gatewayName)
{
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(gatewayName));
return Convert.ToHexString(digest.AsSpan(0, 2)).ToLowerInvariant();
}
/// <summary>
/// Converts a reply subject to gateway form with an explicit hash segment.
/// Format: <c>_GR_.{clusterId}.{hash}.{originalReply}</c>.
@@ -75,14 +123,14 @@ public static class ReplyMapper
{
restoredReply = string.Empty;
if (!HasGatewayReplyPrefix(gatewayReply))
if (!IsGatewayRoutedSubject(gatewayReply, out _))
return false;
var current = gatewayReply!;
while (HasGatewayReplyPrefix(current))
while (IsGatewayRoutedSubject(current, out var isOldPrefix))
{
// Skip the "_GR_." prefix
var afterPrefix = current[GatewayReplyPrefix.Length..];
var prefixLen = isOldPrefix ? OldGatewayReplyPrefixLen : GatewayReplyPrefixLen;
var afterPrefix = current[prefixLen..];
// Find the first dot (end of clusterId)
var firstDot = afterPrefix.IndexOf('.');
@@ -117,10 +165,10 @@ public static class ReplyMapper
{
clusterId = string.Empty;
if (!HasGatewayReplyPrefix(gatewayReply))
if (!IsGatewayRoutedSubject(gatewayReply, out var isOldPrefix))
return false;
var afterPrefix = gatewayReply![GatewayReplyPrefix.Length..];
var afterPrefix = gatewayReply![(isOldPrefix ? OldGatewayReplyPrefixLen : GatewayReplyPrefixLen)..];
var dot = afterPrefix.IndexOf('.');
if (dot <= 0)
return false;
@@ -137,10 +185,10 @@ public static class ReplyMapper
{
hash = 0;
if (!HasGatewayReplyPrefix(gatewayReply))
if (!IsGatewayRoutedSubject(gatewayReply, out var isOldPrefix))
return false;
var afterPrefix = gatewayReply![GatewayReplyPrefix.Length..];
var afterPrefix = gatewayReply![(isOldPrefix ? OldGatewayReplyPrefixLen : GatewayReplyPrefixLen)..];
// Skip clusterId
var firstDot = afterPrefix.IndexOf('.');