using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
namespace NATS.Server.LeafNodes.Tests.LeafNodes;
///
/// Unit tests for leaf node permission and account syncing (Gap 12.2).
/// Verifies , ,
/// , and
/// .
/// Go reference: leafnode.go — sendPermsAndAccountInfo, initLeafNodeSmapAndSendSubs.
///
public class LeafPermissionSyncTests
{
// ── LeafConnection.SetPermissions ─────────────────────────────────────────
// Go: leafnode.go — sendPermsAndAccountInfo sets client.perm.pub.allow
[Fact]
public async Task SetPermissions_SetsPublishAllow()
{
await using var leaf = await CreateConnectedLeafAsync();
leaf.SetPermissions(["orders.*", "events.>"], null);
leaf.AllowedPublishSubjects.Count.ShouldBe(2);
leaf.AllowedPublishSubjects.ShouldContain("orders.*");
leaf.AllowedPublishSubjects.ShouldContain("events.>");
}
// Go: leafnode.go — sendPermsAndAccountInfo sets client.perm.sub.allow
[Fact]
public async Task SetPermissions_SetsSubscribeAllow()
{
await using var leaf = await CreateConnectedLeafAsync();
leaf.SetPermissions(null, ["metrics.cpu", "metrics.memory"]);
leaf.AllowedSubscribeSubjects.Count.ShouldBe(2);
leaf.AllowedSubscribeSubjects.ShouldContain("metrics.cpu");
leaf.AllowedSubscribeSubjects.ShouldContain("metrics.memory");
}
// Go: leafnode.go — sendPermsAndAccountInfo marks perms as synced
[Fact]
public async Task SetPermissions_MarksPermsSynced()
{
await using var leaf = await CreateConnectedLeafAsync();
leaf.PermsSynced.ShouldBeFalse();
leaf.SetPermissions(["foo.*"], ["bar.*"]);
leaf.PermsSynced.ShouldBeTrue();
}
// Go: leafnode.go — clearing perms via null resets the allow lists
[Fact]
public async Task SetPermissions_NullLists_ClearsPermissions()
{
await using var leaf = await CreateConnectedLeafAsync();
leaf.SetPermissions(["orders.*"], ["events.*"]);
leaf.AllowedPublishSubjects.Count.ShouldBe(1);
leaf.AllowedSubscribeSubjects.Count.ShouldBe(1);
// Passing null clears both lists but still marks PermsSynced
leaf.SetPermissions(null, null);
leaf.AllowedPublishSubjects.ShouldBeEmpty();
leaf.AllowedSubscribeSubjects.ShouldBeEmpty();
leaf.PermsSynced.ShouldBeTrue();
}
// ── LeafNodeManager.SendPermsAndAccountInfo ───────────────────────────────
// Go: leafnode.go — sendPermsAndAccountInfo for known connection
[Fact]
public async Task SendPermsAndAccountInfo_ExistingConnection_SyncsPerms()
{
await using var ctx = await CreateManagerWithConnectionAsync();
var result = ctx.Manager.SendPermsAndAccountInfo(
ctx.ConnectionId,
"myaccount",
["pub.>"],
["sub.>"]);
result.Found.ShouldBeTrue();
result.PermsSynced.ShouldBeTrue();
result.PublishAllowCount.ShouldBe(1);
result.SubscribeAllowCount.ShouldBe(1);
}
// Go: leafnode.go — sendPermsAndAccountInfo returns early when connection not found
[Fact]
public async Task SendPermsAndAccountInfo_NonExistent_ReturnsNotFound()
{
await using var ctx = await CreateManagerWithConnectionAsync();
var result = ctx.Manager.SendPermsAndAccountInfo(
"no-such-connection-id",
"account",
["foo.*"],
null);
result.Found.ShouldBeFalse();
result.PermsSynced.ShouldBeFalse();
result.PublishAllowCount.ShouldBe(0);
result.SubscribeAllowCount.ShouldBe(0);
}
// Go: leafnode.go — sendPermsAndAccountInfo sets client.acc (account name)
[Fact]
public async Task SendPermsAndAccountInfo_SetsAccountName()
{
await using var ctx = await CreateManagerWithConnectionAsync();
var result = ctx.Manager.SendPermsAndAccountInfo(
ctx.ConnectionId,
"tenant-alpha",
null,
null);
result.Found.ShouldBeTrue();
result.AccountName.ShouldBe("tenant-alpha");
}
// ── LeafNodeManager.InitLeafNodeSmapAndSendSubs ───────────────────────────
// Go: leafnode.go — initLeafNodeSmapAndSendSubs returns subject count
[Fact]
public async Task InitLeafNodeSmapAndSendSubs_ReturnSubjectCount()
{
await using var ctx = await CreateManagerWithConnectionAsync();
var count = ctx.Manager.InitLeafNodeSmapAndSendSubs(
ctx.ConnectionId,
["orders.new", "orders.updated", "events.>"]);
count.ShouldBe(3);
}
// ── LeafNodeManager.GetPermSyncStatus ─────────────────────────────────────
// Go: leafnode.go — GetPermSyncStatus returns status for known connection
[Fact]
public async Task GetPermSyncStatus_FoundConnection_ReturnsStatus()
{
await using var ctx = await CreateManagerWithConnectionAsync();
ctx.Manager.SendPermsAndAccountInfo(ctx.ConnectionId, "acct", ["pub.*"], ["sub.*"]);
var status = ctx.Manager.GetPermSyncStatus(ctx.ConnectionId);
status.Found.ShouldBeTrue();
status.PermsSynced.ShouldBeTrue();
status.AccountName.ShouldBe("acct");
status.PublishAllowCount.ShouldBe(1);
status.SubscribeAllowCount.ShouldBe(1);
}
// Go: leafnode.go — GetPermSyncStatus returns not-found for unknown ID
[Fact]
public async Task GetPermSyncStatus_NotFound_ReturnsNotFound()
{
await using var ctx = await CreateManagerWithConnectionAsync();
var status = ctx.Manager.GetPermSyncStatus("unknown-id");
status.Found.ShouldBeFalse();
status.PermsSynced.ShouldBeFalse();
status.AccountName.ShouldBeNull();
status.PublishAllowCount.ShouldBe(0);
status.SubscribeAllowCount.ShouldBe(0);
}
// ── Helpers ───────────────────────────────────────────────────────────────
///
/// Creates a connected pair of sockets and returns the server-side LeafConnection
/// after completing an outbound handshake. Callers must await-dispose the returned
/// connection to avoid socket leaks.
///
private static async Task CreateConnectedLeafAsync()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
var serverSocket = await listener.AcceptSocketAsync();
listener.Stop();
var leaf = new LeafConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
// Answer the handshake from the remote side
var line = await ReadLineAsync(clientSocket, cts.Token);
line.ShouldStartWith("LEAF ");
await WriteLineAsync(clientSocket, "LEAF REMOTE", cts.Token);
await handshakeTask;
clientSocket.Close();
return leaf;
}
///
/// Context returned by .
/// Disposes the manager and drains sockets on disposal.
///
private sealed class ManagerContext : IAsyncDisposable
{
private readonly Socket _remoteSocket;
private readonly CancellationTokenSource _cts;
public ManagerContext(LeafNodeManager manager, string connectionId, Socket remoteSocket, CancellationTokenSource cts)
{
Manager = manager;
ConnectionId = connectionId;
_remoteSocket = remoteSocket;
_cts = cts;
}
public LeafNodeManager Manager { get; }
public string ConnectionId { get; }
public async ValueTask DisposeAsync()
{
_remoteSocket.Close();
await Manager.DisposeAsync();
_cts.Dispose();
}
}
///
/// Starts a , establishes one inbound leaf connection, waits
/// for registration, and returns a context containing the manager plus the registered
/// connection ID.
///
private static async Task CreateManagerWithConnectionAsync()
{
var options = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 };
var manager = new LeafNodeManager(
options,
new ServerStats(),
"HUB-SERVER",
_ => { },
_ => { },
NullLogger.Instance);
var cts = new CancellationTokenSource();
await manager.StartAsync(cts.Token);
// Connect a raw socket acting as the spoke (inbound to the manager's listener)
var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, options.Port);
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// Set up a TaskCompletionSource that fires as soon as the manager registers
// the inbound connection — avoids any polling or timing-dependent delays.
var registered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
manager.OnConnectionRegistered = id => registered.TrySetResult(id);
timeoutCts.Token.Register(() => registered.TrySetCanceled(timeoutCts.Token));
// For inbound connections the manager reads first, then writes
await WriteLineAsync(remoteSocket, "LEAF SPOKE1", timeoutCts.Token);
var response = await ReadLineAsync(remoteSocket, timeoutCts.Token);
response.ShouldStartWith("LEAF ");
var connectionId = await registered.Task;
connectionId.ShouldNotBeNull("Manager should have registered the inbound connection");
return new ManagerContext(manager, connectionId, remoteSocket, cts);
}
private static async Task ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List(64);
var single = new byte[1];
while (true)
{
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
if (read == 0) break;
if (single[0] == (byte)'\n') break;
if (single[0] != (byte)'\r') bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
}