Files
natsdotnet/tests/NATS.Server.LeafNodes.Tests/LeafNodes/LeafPermissionSyncTests.cs
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

297 lines
11 KiB
C#

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;
/// <summary>
/// Unit tests for leaf node permission and account syncing (Gap 12.2).
/// Verifies <see cref="LeafConnection.SetPermissions"/>, <see cref="LeafNodeManager.SendPermsAndAccountInfo"/>,
/// <see cref="LeafNodeManager.InitLeafNodeSmapAndSendSubs"/>, and
/// <see cref="LeafNodeManager.GetPermSyncStatus"/>.
/// Go reference: leafnode.go — sendPermsAndAccountInfo, initLeafNodeSmapAndSendSubs.
/// </summary>
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 ───────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
private static async Task<LeafConnection> 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;
}
/// <summary>
/// Context returned by <see cref="CreateManagerWithConnectionAsync"/>.
/// Disposes the manager and drains sockets on disposal.
/// </summary>
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();
}
}
/// <summary>
/// Starts a <see cref="LeafNodeManager"/>, establishes one inbound leaf connection, waits
/// for registration, and returns a context containing the manager plus the registered
/// connection ID.
/// </summary>
private static async Task<ManagerContext> CreateManagerWithConnectionAsync()
{
var options = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 };
var manager = new LeafNodeManager(
options,
new ServerStats(),
"HUB-SERVER",
_ => { },
_ => { },
NullLogger<LeafNodeManager>.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<string>(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<string> ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List<byte>(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();
}