feat: Wave 6 batch 2 — accounts/auth, gateways, routes, JetStream API, JetStream cluster tests
Add comprehensive Go-parity test coverage across 5 subsystems: - Accounts/Auth: isolation, import/export, auth mechanisms, permissions (82 tests) - Gateways: connection, forwarding, interest mode, config (106 tests) - Routes: connection, subscription, forwarding, config validation (78 tests) - JetStream API: stream/consumer CRUD, pub/sub, features, admin (234 tests) - JetStream Cluster: streams, consumers, failover, meta (108 tests) Total: ~608 new test annotations across 22 files (+13,844 lines) All tests pass individually; suite total: 2,283 passing, 3 skipped
This commit is contained in:
442
tests/NATS.Server.Tests/Accounts/PermissionTests.cs
Normal file
442
tests/NATS.Server.Tests/Accounts/PermissionTests.cs
Normal file
@@ -0,0 +1,442 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.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 int GetFreePort()
|
||||
{
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
return ((IPEndPoint)sock.LocalEndPoint!).Port;
|
||||
}
|
||||
|
||||
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
|
||||
{
|
||||
var port = 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 — message was blocked by permissions
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user