Files
natsdotnet/tests/NATS.Server.Tests/Auth/AccountGoParityTests.cs
Joseph Doherty 774717d57c feat: add per-client trace delivery and echo control (Gap 5.7)
Implements ClientTraceInfo with TraceMsgDelivery recording and per-client
echo suppression; fixes AccountGoParityTests namespace ambiguity caused by
the new NATS.Server.Tests.Subscriptions test namespace.
2026-02-25 11:32:00 -05:00

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();
}
}