Files
Joseph Doherty 3f7d896a34 refactor: extract NATS.Server.LeafNodes.Tests project
Move 28 leaf node test files from NATS.Server.Tests into a dedicated
NATS.Server.LeafNodes.Tests project. Update namespaces, add
InternalsVisibleTo, register in solution file. Replace all Task.Delay
polling loops with PollHelper.WaitUntilAsync/YieldForAsync from
TestUtilities. Replace private ReadUntilAsync in LeafProtocolTests
with SocketTestHelper.ReadUntilAsync.

All 281 tests pass.
2026-03-12 15:23:33 -04:00

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.LeafNodes.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");
}
}