Implements ClientTraceInfo with TraceMsgDelivery recording and per-client echo suppression; fixes AccountGoParityTests namespace ambiguity caused by the new NATS.Server.Tests.Subscriptions test namespace.
482 lines
16 KiB
C#
482 lines
16 KiB
C#
// Port of Go server/accounts_test.go — account routing, limits, and import/export parity tests.
|
|
// Reference: golang/nats-server/server/accounts_test.go
|
|
|
|
using NATS.Server.Auth;
|
|
using NATS.Server.Imports;
|
|
using ServerSubscriptions = NATS.Server.Subscriptions;
|
|
|
|
namespace NATS.Server.Tests.Auth;
|
|
|
|
/// <summary>
|
|
/// Parity tests ported from Go server/accounts_test.go exercising account
|
|
/// route mappings, connection limits, import/export cycle detection,
|
|
/// system account, and JetStream resource limits.
|
|
/// </summary>
|
|
public class AccountGoParityTests
|
|
{
|
|
// ========================================================================
|
|
// TestAccountBasicRouteMapping
|
|
// Go reference: accounts_test.go:TestAccountBasicRouteMapping
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void BasicRouteMapping_SubjectIsolation()
|
|
{
|
|
// Go: TestAccountBasicRouteMapping — messages are isolated to accounts.
|
|
// Different accounts have independent subscription namespaces.
|
|
using var accA = new Account("A");
|
|
using var accB = new Account("B");
|
|
|
|
// Add subscriptions to account A's SubList
|
|
var subA = new ServerSubscriptions.Subscription { Subject = "foo", Sid = "1" };
|
|
accA.SubList.Insert(subA);
|
|
|
|
// Account B should not see account A's subscriptions
|
|
var resultB = accB.SubList.Match("foo");
|
|
resultB.PlainSubs.Length.ShouldBe(0);
|
|
|
|
// Account A should see its own subscription
|
|
var resultA = accA.SubList.Match("foo");
|
|
resultA.PlainSubs.Length.ShouldBe(1);
|
|
resultA.PlainSubs[0].ShouldBe(subA);
|
|
}
|
|
|
|
// ========================================================================
|
|
// TestAccountWildcardRouteMapping
|
|
// Go reference: accounts_test.go:TestAccountWildcardRouteMapping
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void WildcardRouteMapping_PerAccountMatching()
|
|
{
|
|
// Go: TestAccountWildcardRouteMapping — wildcards work per-account.
|
|
using var acc = new Account("TEST");
|
|
|
|
var sub1 = new ServerSubscriptions.Subscription { Subject = "orders.*", Sid = "1" };
|
|
var sub2 = new ServerSubscriptions.Subscription { Subject = "orders.>", Sid = "2" };
|
|
acc.SubList.Insert(sub1);
|
|
acc.SubList.Insert(sub2);
|
|
|
|
var result = acc.SubList.Match("orders.new");
|
|
result.PlainSubs.Length.ShouldBe(2);
|
|
|
|
var result2 = acc.SubList.Match("orders.new.item");
|
|
result2.PlainSubs.Length.ShouldBe(1); // only "orders.>" matches
|
|
result2.PlainSubs[0].ShouldBe(sub2);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Connection limits
|
|
// Go reference: accounts_test.go:TestAccountConnsLimitExceededAfterUpdate
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void ConnectionLimit_ExceededAfterUpdate()
|
|
{
|
|
// Go: TestAccountConnsLimitExceededAfterUpdate — reducing max connections
|
|
// below current count prevents new connections.
|
|
using var acc = new Account("TEST") { MaxConnections = 5 };
|
|
|
|
// Add 5 clients
|
|
for (ulong i = 1; i <= 5; i++)
|
|
acc.AddClient(i).ShouldBeTrue();
|
|
|
|
acc.ClientCount.ShouldBe(5);
|
|
|
|
// 6th client should fail
|
|
acc.AddClient(6).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void ConnectionLimit_RemoveAllowsNew()
|
|
{
|
|
// Go: removing a client frees a slot.
|
|
using var acc = new Account("TEST") { MaxConnections = 2 };
|
|
|
|
acc.AddClient(1).ShouldBeTrue();
|
|
acc.AddClient(2).ShouldBeTrue();
|
|
acc.AddClient(3).ShouldBeFalse();
|
|
|
|
acc.RemoveClient(1);
|
|
acc.AddClient(3).ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void ConnectionLimit_ZeroMeansUnlimited()
|
|
{
|
|
// Go: MaxConnections=0 means unlimited.
|
|
using var acc = new Account("TEST") { MaxConnections = 0 };
|
|
|
|
for (ulong i = 1; i <= 100; i++)
|
|
acc.AddClient(i).ShouldBeTrue();
|
|
|
|
acc.ClientCount.ShouldBe(100);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Subscription limits
|
|
// Go reference: accounts_test.go TestAccountUserSubPermsWithQueueGroups
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void SubscriptionLimit_Enforced()
|
|
{
|
|
// Go: TestAccountUserSubPermsWithQueueGroups — subscription count limits.
|
|
using var acc = new Account("TEST") { MaxSubscriptions = 3 };
|
|
|
|
acc.IncrementSubscriptions().ShouldBeTrue();
|
|
acc.IncrementSubscriptions().ShouldBeTrue();
|
|
acc.IncrementSubscriptions().ShouldBeTrue();
|
|
acc.IncrementSubscriptions().ShouldBeFalse();
|
|
|
|
acc.SubscriptionCount.ShouldBe(3);
|
|
}
|
|
|
|
[Fact]
|
|
public void SubscriptionLimit_DecrementAllowsNew()
|
|
{
|
|
using var acc = new Account("TEST") { MaxSubscriptions = 2 };
|
|
|
|
acc.IncrementSubscriptions().ShouldBeTrue();
|
|
acc.IncrementSubscriptions().ShouldBeTrue();
|
|
acc.IncrementSubscriptions().ShouldBeFalse();
|
|
|
|
acc.DecrementSubscriptions();
|
|
acc.IncrementSubscriptions().ShouldBeTrue();
|
|
}
|
|
|
|
// ========================================================================
|
|
// System account
|
|
// Go reference: events_test.go:TestSystemAccountNewConnection
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void SystemAccount_IsSystemAccountFlag()
|
|
{
|
|
// Go: TestSystemAccountNewConnection — system account identification.
|
|
using var sysAcc = new Account(Account.SystemAccountName) { IsSystemAccount = true };
|
|
using var globalAcc = new Account(Account.GlobalAccountName);
|
|
|
|
sysAcc.IsSystemAccount.ShouldBeTrue();
|
|
sysAcc.Name.ShouldBe("$SYS");
|
|
|
|
globalAcc.IsSystemAccount.ShouldBeFalse();
|
|
globalAcc.Name.ShouldBe("$G");
|
|
}
|
|
|
|
// ========================================================================
|
|
// Import/Export cycle detection
|
|
// Go reference: accounts_test.go — addServiceImport with checkForImportCycle
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void ImportExport_DirectCycleDetected()
|
|
{
|
|
// Go: cycle detection prevents A importing from B when B imports from A.
|
|
using var accA = new Account("A");
|
|
using var accB = new Account("B");
|
|
|
|
accA.AddServiceExport("svc.a", ServiceResponseType.Singleton, [accB]);
|
|
accB.AddServiceExport("svc.b", ServiceResponseType.Singleton, [accA]);
|
|
|
|
// A imports from B
|
|
accA.AddServiceImport(accB, "from.b", "svc.b");
|
|
|
|
// B importing from A would create a cycle: B -> A -> B
|
|
var ex = Should.Throw<InvalidOperationException>(() =>
|
|
accB.AddServiceImport(accA, "from.a", "svc.a"));
|
|
ex.Message.ShouldContain("cycle");
|
|
}
|
|
|
|
[Fact]
|
|
public void ImportExport_IndirectCycleDetected()
|
|
{
|
|
// Go: indirect cycles through A -> B -> C -> A are detected.
|
|
using var accA = new Account("A");
|
|
using var accB = new Account("B");
|
|
using var accC = new Account("C");
|
|
|
|
accA.AddServiceExport("svc.a", ServiceResponseType.Singleton, [accC]);
|
|
accB.AddServiceExport("svc.b", ServiceResponseType.Singleton, [accA]);
|
|
accC.AddServiceExport("svc.c", ServiceResponseType.Singleton, [accB]);
|
|
|
|
// A -> B
|
|
accA.AddServiceImport(accB, "from.b", "svc.b");
|
|
// B -> C
|
|
accB.AddServiceImport(accC, "from.c", "svc.c");
|
|
|
|
// C -> A would close the cycle: C -> A -> B -> C
|
|
var ex = Should.Throw<InvalidOperationException>(() =>
|
|
accC.AddServiceImport(accA, "from.a", "svc.a"));
|
|
ex.Message.ShouldContain("cycle");
|
|
}
|
|
|
|
[Fact]
|
|
public void ImportExport_NoCycle_Succeeds()
|
|
{
|
|
// Go: linear import chain A -> B -> C is allowed.
|
|
using var accA = new Account("A");
|
|
using var accB = new Account("B");
|
|
using var accC = new Account("C");
|
|
|
|
accB.AddServiceExport("svc.b", ServiceResponseType.Singleton, [accA]);
|
|
accC.AddServiceExport("svc.c", ServiceResponseType.Singleton, [accB]);
|
|
|
|
accA.AddServiceImport(accB, "from.b", "svc.b");
|
|
accB.AddServiceImport(accC, "from.c", "svc.c");
|
|
// No exception — linear chain is allowed.
|
|
}
|
|
|
|
[Fact]
|
|
public void ImportExport_UnauthorizedAccount_Throws()
|
|
{
|
|
// Go: unauthorized import throws.
|
|
using var accA = new Account("A");
|
|
using var accB = new Account("B");
|
|
using var accC = new Account("C");
|
|
|
|
// B exports only to C, not A
|
|
accB.AddServiceExport("svc.b", ServiceResponseType.Singleton, [accC]);
|
|
|
|
Should.Throw<UnauthorizedAccessException>(() =>
|
|
accA.AddServiceImport(accB, "from.b", "svc.b"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ImportExport_NoExport_Throws()
|
|
{
|
|
// Go: importing a non-existent export throws.
|
|
using var accA = new Account("A");
|
|
using var accB = new Account("B");
|
|
|
|
Should.Throw<InvalidOperationException>(() =>
|
|
accA.AddServiceImport(accB, "from.b", "svc.nonexistent"));
|
|
}
|
|
|
|
// ========================================================================
|
|
// Stream import/export
|
|
// Go reference: accounts_test.go TestAccountBasicRouteMapping (stream exports)
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void StreamImportExport_BasicFlow()
|
|
{
|
|
// Go: basic stream export from A, imported by B.
|
|
using var accA = new Account("A");
|
|
using var accB = new Account("B");
|
|
|
|
accA.AddStreamExport("events.>", [accB]);
|
|
accB.AddStreamImport(accA, "events.>", "imported.events.>");
|
|
|
|
accB.Imports.Streams.Count.ShouldBe(1);
|
|
accB.Imports.Streams[0].From.ShouldBe("events.>");
|
|
accB.Imports.Streams[0].To.ShouldBe("imported.events.>");
|
|
}
|
|
|
|
[Fact]
|
|
public void StreamImport_Unauthorized_Throws()
|
|
{
|
|
using var accA = new Account("A");
|
|
using var accB = new Account("B");
|
|
using var accC = new Account("C");
|
|
|
|
accA.AddStreamExport("events.>", [accC]); // only C authorized
|
|
|
|
Should.Throw<UnauthorizedAccessException>(() =>
|
|
accB.AddStreamImport(accA, "events.>", "imported.>"));
|
|
}
|
|
|
|
[Fact]
|
|
public void StreamImport_NoExport_Throws()
|
|
{
|
|
using var accA = new Account("A");
|
|
using var accB = new Account("B");
|
|
|
|
Should.Throw<InvalidOperationException>(() =>
|
|
accB.AddStreamImport(accA, "nonexistent.>", "imported.>"));
|
|
}
|
|
|
|
// ========================================================================
|
|
// JetStream account limits
|
|
// Go reference: accounts_test.go (JS limits section)
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void JetStreamLimits_MaxStreams_Enforced()
|
|
{
|
|
// Go: per-account JetStream stream limit.
|
|
using var acc = new Account("TEST")
|
|
{
|
|
JetStreamLimits = new AccountLimits { MaxStreams = 2 },
|
|
};
|
|
|
|
acc.TryReserveStream().ShouldBeTrue();
|
|
acc.TryReserveStream().ShouldBeTrue();
|
|
acc.TryReserveStream().ShouldBeFalse();
|
|
|
|
acc.ReleaseStream();
|
|
acc.TryReserveStream().ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void JetStreamLimits_MaxConsumers_Enforced()
|
|
{
|
|
using var acc = new Account("TEST")
|
|
{
|
|
JetStreamLimits = new AccountLimits { MaxConsumers = 3 },
|
|
};
|
|
|
|
acc.TryReserveConsumer().ShouldBeTrue();
|
|
acc.TryReserveConsumer().ShouldBeTrue();
|
|
acc.TryReserveConsumer().ShouldBeTrue();
|
|
acc.TryReserveConsumer().ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void JetStreamLimits_MaxStorage_Enforced()
|
|
{
|
|
using var acc = new Account("TEST")
|
|
{
|
|
JetStreamLimits = new AccountLimits { MaxStorage = 1024 },
|
|
};
|
|
|
|
acc.TrackStorageDelta(512).ShouldBeTrue();
|
|
acc.TrackStorageDelta(512).ShouldBeTrue();
|
|
acc.TrackStorageDelta(1).ShouldBeFalse(); // would exceed
|
|
|
|
acc.TrackStorageDelta(-256).ShouldBeTrue(); // free some
|
|
acc.TrackStorageDelta(256).ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void JetStreamLimits_Unlimited_AllowsAny()
|
|
{
|
|
using var acc = new Account("TEST")
|
|
{
|
|
JetStreamLimits = AccountLimits.Unlimited,
|
|
};
|
|
|
|
for (int i = 0; i < 100; i++)
|
|
{
|
|
acc.TryReserveStream().ShouldBeTrue();
|
|
acc.TryReserveConsumer().ShouldBeTrue();
|
|
}
|
|
|
|
acc.TrackStorageDelta(long.MaxValue / 2).ShouldBeTrue();
|
|
}
|
|
|
|
// ========================================================================
|
|
// Account stats tracking
|
|
// Go reference: accounts_test.go TestAccountReqMonitoring
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void AccountStats_InboundOutbound()
|
|
{
|
|
// Go: TestAccountReqMonitoring — per-account message/byte stats.
|
|
using var acc = new Account("TEST");
|
|
|
|
acc.IncrementInbound(10, 1024);
|
|
acc.IncrementOutbound(5, 512);
|
|
|
|
acc.InMsgs.ShouldBe(10);
|
|
acc.InBytes.ShouldBe(1024);
|
|
acc.OutMsgs.ShouldBe(5);
|
|
acc.OutBytes.ShouldBe(512);
|
|
}
|
|
|
|
[Fact]
|
|
public void AccountStats_CumulativeAcrossIncrements()
|
|
{
|
|
using var acc = new Account("TEST");
|
|
|
|
acc.IncrementInbound(10, 1024);
|
|
acc.IncrementInbound(5, 512);
|
|
|
|
acc.InMsgs.ShouldBe(15);
|
|
acc.InBytes.ShouldBe(1536);
|
|
}
|
|
|
|
// ========================================================================
|
|
// User revocation
|
|
// Go reference: accounts_test.go TestAccountClaimsUpdatesWithServiceImports
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void UserRevocation_RevokedBeforeIssuedAt()
|
|
{
|
|
// Go: TestAccountClaimsUpdatesWithServiceImports — user revocation by NKey.
|
|
using var acc = new Account("TEST");
|
|
|
|
acc.RevokeUser("UABC123", 1000);
|
|
|
|
// JWT issued at 999 (before revocation) is revoked
|
|
acc.IsUserRevoked("UABC123", 999).ShouldBeTrue();
|
|
// JWT issued at 1000 (exactly at revocation) is revoked
|
|
acc.IsUserRevoked("UABC123", 1000).ShouldBeTrue();
|
|
// JWT issued at 1001 (after revocation) is NOT revoked
|
|
acc.IsUserRevoked("UABC123", 1001).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void UserRevocation_WildcardRevokesAll()
|
|
{
|
|
using var acc = new Account("TEST");
|
|
|
|
acc.RevokeUser("*", 500);
|
|
|
|
acc.IsUserRevoked("ANY_USER_1", 499).ShouldBeTrue();
|
|
acc.IsUserRevoked("ANY_USER_2", 500).ShouldBeTrue();
|
|
acc.IsUserRevoked("ANY_USER_3", 501).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void UserRevocation_UnrevokedUser_NotRevoked()
|
|
{
|
|
using var acc = new Account("TEST");
|
|
acc.IsUserRevoked("UNKNOWN_USER", 1000).ShouldBeFalse();
|
|
}
|
|
|
|
// ========================================================================
|
|
// Remove service/stream imports
|
|
// Go reference: accounts_test.go TestAccountRouteMappingChangesAfterClientStart
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public void RemoveServiceImport_RemovesCorrectly()
|
|
{
|
|
// Go: TestAccountRouteMappingChangesAfterClientStart — dynamic import removal.
|
|
using var accA = new Account("A");
|
|
using var accB = new Account("B");
|
|
|
|
accB.AddServiceExport("svc.b", ServiceResponseType.Singleton, [accA]);
|
|
accA.AddServiceImport(accB, "from.b", "svc.b");
|
|
accA.Imports.Services.ContainsKey("from.b").ShouldBeTrue();
|
|
|
|
accA.RemoveServiceImport("from.b").ShouldBeTrue();
|
|
accA.Imports.Services.ContainsKey("from.b").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void RemoveStreamImport_RemovesCorrectly()
|
|
{
|
|
using var accA = new Account("A");
|
|
using var accB = new Account("B");
|
|
|
|
accA.AddStreamExport("events.>", [accB]);
|
|
accB.AddStreamImport(accA, "events.>", "imported.>");
|
|
accB.Imports.Streams.Count.ShouldBe(1);
|
|
|
|
accB.RemoveStreamImport("events.>").ShouldBeTrue();
|
|
accB.Imports.Streams.Count.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void RemoveNonexistent_ReturnsFalse()
|
|
{
|
|
using var acc = new Account("TEST");
|
|
acc.RemoveServiceImport("nonexistent").ShouldBeFalse();
|
|
acc.RemoveStreamImport("nonexistent").ShouldBeFalse();
|
|
}
|
|
}
|