Files
natsdotnet/tests/NATS.Server.Auth.Tests/Accounts/PermissionTests.cs
Joseph Doherty 36b9dfa654 refactor: extract NATS.Server.Auth.Tests project
Move 50 auth/accounts/permissions/JWT/NKey test files from
NATS.Server.Tests into a dedicated NATS.Server.Auth.Tests project.
Update namespaces, replace private GetFreePort/ReadUntilAsync helpers
with TestUtilities calls, replace Task.Delay with TaskCompletionSource
in test doubles, and add InternalsVisibleTo.

690 tests pass.
2026-03-12 15:54:07 -04:00

439 lines
14 KiB
C#

using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.Tests.Accounts;
/// <summary>
/// Tests for publish/subscribe permission enforcement, account-level limits,
/// and per-user permission isolation.
/// Reference: Go auth_test.go — TestUserClone* (permission structure tests)
/// Reference: Go accounts_test.go — account limits (max connections, max subscriptions).
/// </summary>
public class PermissionTests
{
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
{
var port = TestPortAllocator.GetFreePort();
options.Port = port;
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
return (server, port, cts);
}
private static bool ExceptionChainContains(Exception ex, string substring)
{
Exception? current = ex;
while (current != null)
{
if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
return true;
current = current.InnerException;
}
return false;
}
// Go: Permissions — publish allow list
[Fact]
public void Publish_allow_list_only()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission { Allow = ["foo.>", "bar.*"] },
});
perms.ShouldNotBeNull();
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
perms.IsPublishAllowed("foo.bar.baz").ShouldBeTrue();
perms.IsPublishAllowed("bar.one").ShouldBeTrue();
perms.IsPublishAllowed("baz.one").ShouldBeFalse();
}
// Go: Permissions — publish deny list
[Fact]
public void Publish_deny_list_only()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission { Deny = ["secret.>"] },
});
perms.ShouldNotBeNull();
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
perms.IsPublishAllowed("secret.data").ShouldBeFalse();
perms.IsPublishAllowed("secret.nested.deep").ShouldBeFalse();
}
// Go: Permissions — publish allow + deny combined
[Fact]
public void Publish_allow_and_deny_combined()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission
{
Allow = ["events.>"],
Deny = ["events.internal.>"],
},
});
perms.ShouldNotBeNull();
perms.IsPublishAllowed("events.public.data").ShouldBeTrue();
perms.IsPublishAllowed("events.internal.secret").ShouldBeFalse();
}
// Go: Permissions — subscribe allow list
[Fact]
public void Subscribe_allow_list()
{
var perms = ClientPermissions.Build(new Permissions
{
Subscribe = new SubjectPermission { Allow = ["data.>"] },
});
perms.ShouldNotBeNull();
perms.IsSubscribeAllowed("data.updates").ShouldBeTrue();
perms.IsSubscribeAllowed("admin.logs").ShouldBeFalse();
}
// Go: Permissions — subscribe deny list
[Fact]
public void Subscribe_deny_list()
{
var perms = ClientPermissions.Build(new Permissions
{
Subscribe = new SubjectPermission { Deny = ["admin.>"] },
});
perms.ShouldNotBeNull();
perms.IsSubscribeAllowed("data.updates").ShouldBeTrue();
perms.IsSubscribeAllowed("admin.logs").ShouldBeFalse();
}
// Go: Permissions — null permissions allow everything
[Fact]
public void Null_permissions_allows_everything()
{
var perms = ClientPermissions.Build(null);
perms.ShouldBeNull();
}
// Go: Permissions — empty permissions allows everything
[Fact]
public void Empty_permissions_allows_everything()
{
var perms = ClientPermissions.Build(new Permissions());
perms.ShouldBeNull();
}
// Go: Permissions — subscribe allow + deny combined
[Fact]
public void Subscribe_allow_and_deny_combined()
{
var perms = ClientPermissions.Build(new Permissions
{
Subscribe = new SubjectPermission
{
Allow = ["data.>"],
Deny = ["data.secret.>"],
},
});
perms.ShouldNotBeNull();
perms.IsSubscribeAllowed("data.public").ShouldBeTrue();
perms.IsSubscribeAllowed("data.secret.key").ShouldBeFalse();
}
// Go: Permissions — separate publish and subscribe permissions
[Fact]
public void Separate_publish_and_subscribe_permissions()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission { Allow = ["pub.>"] },
Subscribe = new SubjectPermission { Allow = ["sub.>"] },
});
perms.ShouldNotBeNull();
perms.IsPublishAllowed("pub.data").ShouldBeTrue();
perms.IsPublishAllowed("sub.data").ShouldBeFalse();
perms.IsSubscribeAllowed("sub.data").ShouldBeTrue();
perms.IsSubscribeAllowed("pub.data").ShouldBeFalse();
}
// Go: Account limits — max connections
[Fact]
public void Account_enforces_max_connections()
{
var acc = new Account("test") { MaxConnections = 2 };
acc.AddClient(1).ShouldBeTrue();
acc.AddClient(2).ShouldBeTrue();
acc.AddClient(3).ShouldBeFalse(); // exceeds limit
acc.ClientCount.ShouldBe(2);
}
// Go: Account limits — unlimited connections
[Fact]
public void Account_unlimited_connections_when_zero()
{
var acc = new Account("test") { MaxConnections = 0 };
for (ulong i = 1; i <= 100; i++)
acc.AddClient(i).ShouldBeTrue();
acc.ClientCount.ShouldBe(100);
}
// Go: Account limits — max subscriptions
[Fact]
public void Account_enforces_max_subscriptions()
{
var acc = new Account("test") { MaxSubscriptions = 2 };
acc.IncrementSubscriptions().ShouldBeTrue();
acc.IncrementSubscriptions().ShouldBeTrue();
acc.IncrementSubscriptions().ShouldBeFalse();
}
// Go: Account limits — subscription decrement frees slot
[Fact]
public void Account_decrement_subscriptions_frees_slot()
{
var acc = new Account("test") { MaxSubscriptions = 1 };
acc.IncrementSubscriptions().ShouldBeTrue();
acc.DecrementSubscriptions();
acc.IncrementSubscriptions().ShouldBeTrue(); // slot freed
}
// Go: Account limits — max connections via integration
[Fact]
public void Account_remove_client_frees_slot()
{
var acc = new Account("test") { MaxConnections = 1 };
acc.AddClient(1).ShouldBeTrue();
acc.AddClient(2).ShouldBeFalse(); // full
acc.RemoveClient(1);
acc.AddClient(2).ShouldBeTrue(); // slot freed
}
// Go: Account limits — default permissions on account
[Fact]
public void Account_default_permissions()
{
var acc = new Account("test")
{
DefaultPermissions = new Permissions
{
Publish = new SubjectPermission { Allow = ["pub.>"] },
},
};
acc.DefaultPermissions.ShouldNotBeNull();
acc.DefaultPermissions.Publish!.Allow![0].ShouldBe("pub.>");
}
// Go: Account stats tracking
[Fact]
public void Account_tracks_message_stats()
{
var acc = new Account("stats-test");
acc.InMsgs.ShouldBe(0L);
acc.OutMsgs.ShouldBe(0L);
acc.InBytes.ShouldBe(0L);
acc.OutBytes.ShouldBe(0L);
acc.IncrementInbound(5, 1024);
acc.IncrementOutbound(3, 512);
acc.InMsgs.ShouldBe(5L);
acc.InBytes.ShouldBe(1024L);
acc.OutMsgs.ShouldBe(3L);
acc.OutBytes.ShouldBe(512L);
}
// Go: Account — user with publish permission can publish
[Fact]
public async Task User_with_publish_permission_can_publish_and_subscribe()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Users =
[
new User
{
Username = "limited",
Password = "pass",
Permissions = new Permissions
{
Publish = new SubjectPermission { Allow = ["allowed.>"] },
Subscribe = new SubjectPermission { Allow = [">"] },
},
},
],
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://limited:pass@127.0.0.1:{port}",
});
await client.ConnectAsync();
// Subscribe to allowed subjects
await using var sub = await client.SubscribeCoreAsync<string>("allowed.test");
await client.PingAsync();
// Publish to allowed subject
await client.PublishAsync("allowed.test", "hello");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Data.ShouldBe("hello");
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: Account — user with publish deny
[Fact]
public async Task User_with_publish_deny_blocks_denied_subjects()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Users =
[
new User
{
Username = "limited",
Password = "pass",
Permissions = new Permissions
{
Publish = new SubjectPermission
{
Allow = [">"],
Deny = ["secret.>"],
},
Subscribe = new SubjectPermission { Allow = [">"] },
},
},
],
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://limited:pass@127.0.0.1:{port}",
});
await client.ConnectAsync();
// Subscribe to catch anything
await using var sub = await client.SubscribeCoreAsync<string>("secret.data");
await client.PingAsync();
// Publish to denied subject — server should silently drop
await client.PublishAsync("secret.data", "shouldnt-arrive");
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
try
{
await sub.Msgs.ReadAsync(timeout.Token);
throw new Exception("Should not have received message on denied subject");
}
catch (OperationCanceledException)
{
// Expected — timeout confirms permission denial blocked the message
return;
}
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: Account — user revocation
[Fact]
public void Account_user_revocation()
{
var acc = new Account("test");
acc.IsUserRevoked("user1", 100).ShouldBeFalse();
acc.RevokeUser("user1", 200);
acc.IsUserRevoked("user1", 100).ShouldBeTrue(); // issued before revocation
acc.IsUserRevoked("user1", 200).ShouldBeTrue(); // issued at revocation time
acc.IsUserRevoked("user1", 300).ShouldBeFalse(); // issued after revocation
}
// Go: Account — wildcard user revocation
[Fact]
public void Account_wildcard_user_revocation()
{
var acc = new Account("test");
acc.RevokeUser("*", 500);
acc.IsUserRevoked("anyuser", 400).ShouldBeTrue();
acc.IsUserRevoked("anyuser", 600).ShouldBeFalse();
}
// Go: Account — JetStream stream reservation
[Fact]
public void Account_jetstream_stream_reservation()
{
var acc = new Account("test") { MaxJetStreamStreams = 2 };
acc.TryReserveStream().ShouldBeTrue();
acc.TryReserveStream().ShouldBeTrue();
acc.TryReserveStream().ShouldBeFalse(); // limit reached
acc.JetStreamStreamCount.ShouldBe(2);
acc.ReleaseStream();
acc.JetStreamStreamCount.ShouldBe(1);
acc.TryReserveStream().ShouldBeTrue(); // slot freed
}
// Go: Account limits — permissions cache behavior
[Fact]
public void Permission_cache_returns_consistent_results()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission { Allow = ["foo.>"] },
});
perms.ShouldNotBeNull();
// First call populates cache
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
// Second call uses cache — should return same result
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
// Different subject also cached
perms.IsPublishAllowed("baz.bar").ShouldBeFalse();
perms.IsPublishAllowed("baz.bar").ShouldBeFalse();
}
// Go: Permissions — delivery allowed check
[Fact]
public void Delivery_allowed_respects_deny_list()
{
var perms = ClientPermissions.Build(new Permissions
{
Subscribe = new SubjectPermission { Deny = ["blocked.>"] },
});
perms.ShouldNotBeNull();
perms.IsDeliveryAllowed("normal.subject").ShouldBeTrue();
perms.IsDeliveryAllowed("blocked.secret").ShouldBeFalse();
}
}