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.
181 lines
6.8 KiB
C#
181 lines
6.8 KiB
C#
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");
|
|
}
|
|
}
|