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
443 lines
14 KiB
C#
443 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;
|
|
|
|
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();
|
|
}
|
|
}
|