feat: add leaf connection state validation on reconnect (Gap 12.3)

Adds ValidateRemoteLeafNode to LeafNodeManager with self-connect,
duplicate-connection, and JetStream domain conflict checks, plus
IsSelfConnect, HasConnection, and GetConnectionByRemoteId helpers.
Introduces LeafValidationResult and LeafValidationError types.
Adds 10 unit tests in LeafValidationTests covering all error codes.
This commit is contained in:
Joseph Doherty
2026-02-25 12:10:44 -05:00
parent ef425db187
commit 629bbd13fa
3 changed files with 401 additions and 1 deletions

View File

@@ -0,0 +1,180 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Unit tests for leaf node reconnect state validation (Gap 12.3).
/// Verifies that <see cref="LeafNodeManager.ValidateRemoteLeafNode"/>,
/// <see cref="LeafNodeManager.IsSelfConnect"/>, <see cref="LeafNodeManager.HasConnection"/>,
/// and <see cref="LeafNodeManager.GetConnectionByRemoteId"/> enforce self-connect detection,
/// duplicate-connection rejection, and JetStream domain conflict detection.
/// Go reference: leafnode.go addLeafNodeConnection — duplicate and domain checks.
/// </summary>
public class LeafValidationTests
{
private static LeafNodeManager CreateManager(string serverId = "server-A") =>
new(
options: new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
stats: new ServerStats(),
serverId: serverId,
remoteSubSink: _ => { },
messageSink: _ => { },
logger: NullLogger<LeafNodeManager>.Instance);
/// <summary>
/// Creates a connected socket pair and returns the server-side socket.
/// The caller is responsible for disposing both sockets.
/// </summary>
private static async Task<(Socket serverSide, Socket clientSide, TcpListener listener)> CreateSocketPairAsync()
{
var tcpListener = new TcpListener(IPAddress.Loopback, 0);
tcpListener.Start();
var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)tcpListener.LocalEndpoint).Port);
var serverSocket = await tcpListener.AcceptSocketAsync();
tcpListener.Stop();
return (serverSocket, clientSocket, tcpListener);
}
private static async Task<LeafConnection> CreateConnectionWithRemoteIdAsync(string remoteId, string? jsDomain = null)
{
var (serverSide, clientSide, _) = await CreateSocketPairAsync();
clientSide.Dispose(); // only need the server side for the LeafConnection
var conn = new LeafConnection(serverSide)
{
RemoteId = remoteId,
JetStreamDomain = jsDomain,
};
return conn;
}
// Go: leafnode.go addLeafNodeConnection — happy path with distinct server IDs
[Fact]
public async Task ValidateRemoteLeafNode_Valid_ReturnsValid()
{
var manager = CreateManager("server-A");
await using var conn = await CreateConnectionWithRemoteIdAsync("server-B");
manager.InjectConnectionForTesting(conn);
var result = manager.ValidateRemoteLeafNode("server-C", "$G", null);
result.Valid.ShouldBeTrue();
result.Error.ShouldBeNull();
result.ErrorCode.ShouldBe(LeafValidationError.None);
}
// Go: leafnode.go — loop detection: reject when remote ID matches own server ID
[Fact]
public void ValidateRemoteLeafNode_SelfConnect_ReturnsError()
{
var manager = CreateManager("server-A");
var result = manager.ValidateRemoteLeafNode("server-A", "$G", null);
result.Valid.ShouldBeFalse();
result.ErrorCode.ShouldBe(LeafValidationError.SelfConnect);
result.Error.ShouldNotBeNull();
}
// Go: leafnode.go addLeafNodeConnection checkForDup — existing connection from same remote
[Fact]
public async Task ValidateRemoteLeafNode_DuplicateConnection_ReturnsError()
{
var manager = CreateManager("server-A");
await using var conn = await CreateConnectionWithRemoteIdAsync("server-B");
manager.InjectConnectionForTesting(conn);
var result = manager.ValidateRemoteLeafNode("server-B", "$G", null);
result.Valid.ShouldBeFalse();
result.ErrorCode.ShouldBe(LeafValidationError.DuplicateConnection);
result.Error.ShouldNotBeNull();
}
// Go: leafnode.go addLeafNodeConnection — JetStream domain conflict between existing and incoming
[Fact]
public async Task ValidateRemoteLeafNode_JsDomainConflict_ReturnsError()
{
var manager = CreateManager("server-A");
await using var conn = await CreateConnectionWithRemoteIdAsync("server-B", jsDomain: "domain-hub");
manager.InjectConnectionForTesting(conn);
// server-C tries to connect with a different JS domain
var result = manager.ValidateRemoteLeafNode("server-C", "$G", "domain-spoke");
result.Valid.ShouldBeFalse();
result.ErrorCode.ShouldBe(LeafValidationError.JetStreamDomainConflict);
result.Error.ShouldNotBeNull();
}
// Go: leafnode.go — null JS domain is never a conflict
[Fact]
public async Task ValidateRemoteLeafNode_NullJsDomain_Valid()
{
var manager = CreateManager("server-A");
await using var conn = await CreateConnectionWithRemoteIdAsync("server-B", jsDomain: "domain-hub");
manager.InjectConnectionForTesting(conn);
// Incoming with null domain — no domain conflict check performed
var result = manager.ValidateRemoteLeafNode("server-C", "$G", null);
result.Valid.ShouldBeTrue();
result.ErrorCode.ShouldBe(LeafValidationError.None);
}
// Go: leafnode.go — IsSelfConnect true when IDs match
[Fact]
public void IsSelfConnect_MatchingId_ReturnsTrue()
{
var manager = CreateManager("server-A");
manager.IsSelfConnect("server-A").ShouldBeTrue();
}
// Go: leafnode.go — IsSelfConnect false when IDs differ
[Fact]
public void IsSelfConnect_DifferentId_ReturnsFalse()
{
var manager = CreateManager("server-A");
manager.IsSelfConnect("server-B").ShouldBeFalse();
}
// Go: leafnode.go — HasConnection true when remote ID is registered
[Fact]
public async Task HasConnection_Existing_ReturnsTrue()
{
var manager = CreateManager("server-A");
await using var conn = await CreateConnectionWithRemoteIdAsync("server-B");
manager.InjectConnectionForTesting(conn);
manager.HasConnection("server-B").ShouldBeTrue();
}
// Go: leafnode.go — HasConnection false when remote ID is not registered
[Fact]
public void HasConnection_Missing_ReturnsFalse()
{
var manager = CreateManager("server-A");
manager.HasConnection("server-B").ShouldBeFalse();
}
// Go: leafnode.go — GetConnectionByRemoteId returns the matching connection
[Fact]
public async Task GetConnectionByRemoteId_Found_ReturnsConnection()
{
var manager = CreateManager("server-A");
await using var conn = await CreateConnectionWithRemoteIdAsync("server-B");
manager.InjectConnectionForTesting(conn);
var found = manager.GetConnectionByRemoteId("server-B");
found.ShouldNotBeNull();
found.RemoteId.ShouldBe("server-B");
}
}