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.
This commit is contained in:
Joseph Doherty
2026-03-12 15:54:07 -04:00
parent 0c086522a4
commit 36b9dfa654
53 changed files with 138 additions and 185 deletions

View File

@@ -0,0 +1,173 @@
// Tests for account claim hot-reload with diff-based update detection.
// Go reference: accounts_test.go TestUpdateAccountClaims, updateAccountClaimsWithRefresh (~line 3374).
using NATS.Server.Auth;
namespace NATS.Server.Auth.Tests.Auth;
public class AccountClaimReloadTests
{
// 1. First update: all provided fields are reported as changed.
[Fact]
public void UpdateAccountClaims_FirstUpdate_AllFieldsChanged()
{
var account = new Account("test");
var claims = new AccountClaimData
{
MaxConnections = 10,
MaxSubscriptions = 100,
Nkey = "NKEY123",
Issuer = "ISSUER_OP",
ExpiresAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc),
};
var result = account.UpdateAccountClaims(claims);
result.Changed.ShouldBeTrue();
result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxConnections));
result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxSubscriptions));
result.ChangedFields.ShouldContain(nameof(AccountClaimData.Nkey));
result.ChangedFields.ShouldContain(nameof(AccountClaimData.Issuer));
result.ChangedFields.ShouldContain(nameof(AccountClaimData.ExpiresAt));
result.ChangedFields.Count.ShouldBe(5);
}
// 2. Applying the exact same claims a second time returns Changed=false.
[Fact]
public void UpdateAccountClaims_NoChange_ReturnsFalse()
{
var account = new Account("test");
var claims = new AccountClaimData
{
MaxConnections = 5,
MaxSubscriptions = 50,
Nkey = "NKEY_A",
Issuer = "OP",
};
account.UpdateAccountClaims(claims);
var result = account.UpdateAccountClaims(claims);
result.Changed.ShouldBeFalse();
result.ChangedFields.Count.ShouldBe(0);
}
// 3. Changing MaxConnections is detected.
[Fact]
public void UpdateAccountClaims_MaxConnectionsChanged_Detected()
{
var account = new Account("test");
var initial = new AccountClaimData { MaxConnections = 10 };
account.UpdateAccountClaims(initial);
var updated = new AccountClaimData { MaxConnections = 20 };
var result = account.UpdateAccountClaims(updated);
result.Changed.ShouldBeTrue();
result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxConnections));
account.MaxConnections.ShouldBe(20);
}
// 4. Changing MaxSubscriptions is detected.
[Fact]
public void UpdateAccountClaims_MaxSubscriptionsChanged_Detected()
{
var account = new Account("test");
var initial = new AccountClaimData { MaxSubscriptions = 100 };
account.UpdateAccountClaims(initial);
var updated = new AccountClaimData { MaxSubscriptions = 200 };
var result = account.UpdateAccountClaims(updated);
result.Changed.ShouldBeTrue();
result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxSubscriptions));
account.MaxSubscriptions.ShouldBe(200);
}
// 5. Changing Nkey is detected.
[Fact]
public void UpdateAccountClaims_NkeyChanged_Detected()
{
var account = new Account("test");
var initial = new AccountClaimData { Nkey = "OLD_NKEY" };
account.UpdateAccountClaims(initial);
var updated = new AccountClaimData { Nkey = "NEW_NKEY" };
var result = account.UpdateAccountClaims(updated);
result.Changed.ShouldBeTrue();
result.ChangedFields.ShouldContain(nameof(AccountClaimData.Nkey));
account.Nkey.ShouldBe("NEW_NKEY");
}
// 6. Changing Issuer is detected.
[Fact]
public void UpdateAccountClaims_IssuerChanged_Detected()
{
var account = new Account("test");
var initial = new AccountClaimData { Issuer = "ISSUER_A" };
account.UpdateAccountClaims(initial);
var updated = new AccountClaimData { Issuer = "ISSUER_B" };
var result = account.UpdateAccountClaims(updated);
result.Changed.ShouldBeTrue();
result.ChangedFields.ShouldContain(nameof(AccountClaimData.Issuer));
account.Issuer.ShouldBe("ISSUER_B");
}
// 7. A successful claim update increments the generation counter.
[Fact]
public void UpdateAccountClaims_IncrementsGeneration()
{
var account = new Account("test");
var before = account.GenerationId;
var claims = new AccountClaimData { MaxConnections = 5 };
account.UpdateAccountClaims(claims);
account.GenerationId.ShouldBe(before + 1);
}
// 8. HasClaims is false on a fresh account.
[Fact]
public void HasClaims_BeforeUpdate_ReturnsFalse()
{
var account = new Account("test");
account.HasClaims.ShouldBeFalse();
}
// 9. HasClaims is true after the first update.
[Fact]
public void HasClaims_AfterUpdate_ReturnsTrue()
{
var account = new Account("test");
var claims = new AccountClaimData { MaxConnections = 1 };
account.UpdateAccountClaims(claims);
account.HasClaims.ShouldBeTrue();
account.CurrentClaims.ShouldNotBeNull();
account.CurrentClaims!.MaxConnections.ShouldBe(1);
}
// 10. ClaimUpdateCount increments only when claims actually change.
[Fact]
public void ClaimUpdateCount_IncrementsOnChange()
{
var account = new Account("test");
account.ClaimUpdateCount.ShouldBe(0);
var claimsA = new AccountClaimData { MaxConnections = 5 };
account.UpdateAccountClaims(claimsA);
account.ClaimUpdateCount.ShouldBe(1);
// Reapplying same claims does NOT increment count.
account.UpdateAccountClaims(claimsA);
account.ClaimUpdateCount.ShouldBe(1);
// Applying different claims does increment.
var claimsB = new AccountClaimData { MaxConnections = 10 };
account.UpdateAccountClaims(claimsB);
account.ClaimUpdateCount.ShouldBe(2);
}
}

View File

@@ -0,0 +1,135 @@
using NATS.Server.Auth;
using Shouldly;
namespace NATS.Server.Auth.Tests.Auth;
// Go reference: server/accounts.go — account expiry / SetExpirationTimer
public sealed class AccountExpirationTests
{
// 1. ExpiresAt_Default_IsNull
// Go reference: accounts.go — account.expiry zero-value
[Fact]
public void ExpiresAt_Default_IsNull()
{
var account = new Account("test-account");
account.ExpiresAt.ShouldBeNull();
}
// 2. SetExpiration_SetsExpiresAt
// Go reference: accounts.go — SetExpirationTimer stores expiry value
[Fact]
public void SetExpiration_SetsExpiresAt()
{
var account = new Account("test-account");
var expiresAt = new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc);
account.SetExpiration(expiresAt);
account.ExpiresAt.ShouldBe(expiresAt);
}
// 3. IsExpired_FutureDate_ReturnsFalse
// Go reference: accounts.go — isExpired() returns false when expiry is in future
[Fact]
public void IsExpired_FutureDate_ReturnsFalse()
{
var account = new Account("test-account");
account.SetExpiration(DateTime.UtcNow.AddHours(1));
account.IsExpired.ShouldBeFalse();
}
// 4. IsExpired_PastDate_ReturnsTrue
// Go reference: accounts.go — isExpired() returns true when past expiry
[Fact]
public void IsExpired_PastDate_ReturnsTrue()
{
var account = new Account("test-account");
account.SetExpiration(DateTime.UtcNow.AddHours(-1));
account.IsExpired.ShouldBeTrue();
}
// 5. ClearExpiration_RemovesExpiry
// Go reference: accounts.go — clearing expiry resets the field to zero
[Fact]
public void ClearExpiration_RemovesExpiry()
{
var account = new Account("test-account");
account.SetExpiration(DateTime.UtcNow.AddHours(1));
account.ExpiresAt.ShouldNotBeNull();
account.ClearExpiration();
account.ExpiresAt.ShouldBeNull();
}
// 6. SetExpirationFromTtl_CalculatesCorrectly
// Go reference: accounts.go — SetExpirationTimer(ttl) sets expiry = now + ttl
[Fact]
public void SetExpirationFromTtl_CalculatesCorrectly()
{
var account = new Account("test-account");
var before = DateTime.UtcNow;
account.SetExpirationFromTtl(TimeSpan.FromHours(1));
var after = DateTime.UtcNow;
account.ExpiresAt.ShouldNotBeNull();
account.ExpiresAt!.Value.ShouldBeGreaterThanOrEqualTo(before.AddHours(1));
account.ExpiresAt.Value.ShouldBeLessThanOrEqualTo(after.AddHours(1));
}
// 7. TimeToExpiry_NoExpiry_ReturnsNull
// Go reference: accounts.go — no expiry set returns nil duration
[Fact]
public void TimeToExpiry_NoExpiry_ReturnsNull()
{
var account = new Account("test-account");
account.TimeToExpiry.ShouldBeNull();
}
// 8. TimeToExpiry_Expired_ReturnsZero
// Go reference: accounts.go — already-expired account has zero remaining time
[Fact]
public void TimeToExpiry_Expired_ReturnsZero()
{
var account = new Account("test-account");
account.SetExpiration(DateTime.UtcNow.AddHours(-1));
account.TimeToExpiry.ShouldBe(TimeSpan.Zero);
}
// 9. TimeToExpiry_Future_ReturnsPositive
// Go reference: accounts.go — unexpired account returns positive remaining duration
[Fact]
public void TimeToExpiry_Future_ReturnsPositive()
{
var account = new Account("test-account");
account.SetExpiration(DateTime.UtcNow.AddHours(1));
var tte = account.TimeToExpiry;
tte.ShouldNotBeNull();
tte!.Value.ShouldBeGreaterThan(TimeSpan.Zero);
}
// 10. GetExpirationInfo_ReturnsCompleteInfo
// Go reference: accounts.go — expiry fields exposed for monitoring / JWT renewal
[Fact]
public void GetExpirationInfo_ReturnsCompleteInfo()
{
var account = new Account("info-account");
var expiresAt = DateTime.UtcNow.AddHours(2);
account.SetExpiration(expiresAt);
var info = account.GetExpirationInfo();
info.AccountName.ShouldBe("info-account");
info.HasExpiration.ShouldBeTrue();
info.ExpiresAt.ShouldBe(expiresAt);
info.IsExpired.ShouldBeFalse();
info.TimeToExpiry.ShouldNotBeNull();
info.TimeToExpiry!.Value.ShouldBeGreaterThan(TimeSpan.Zero);
}
}

View File

@@ -0,0 +1,481 @@
// 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.Auth.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();
}
}

View File

@@ -0,0 +1,211 @@
// Tests for account import/export cycle detection.
// Go reference: accounts_test.go TestAccountImportCycleDetection.
using NATS.Server.Auth;
using NATS.Server.Imports;
namespace NATS.Server.Auth.Tests.Auth;
public class AccountImportExportTests
{
private static Account CreateAccount(string name) => new(name);
private static void SetupServiceExport(Account exporter, string subject, IEnumerable<Account>? approved = null)
{
exporter.AddServiceExport(subject, ServiceResponseType.Singleton, approved);
}
[Fact]
public void AddServiceImport_NoCycle_Succeeds()
{
// A exports "svc.foo", B imports from A — no cycle
var a = CreateAccount("A");
var b = CreateAccount("B");
SetupServiceExport(a, "svc.foo"); // public export (no approved list)
var import = b.AddServiceImport(a, "svc.foo", "svc.foo");
import.ShouldNotBeNull();
import.DestinationAccount.Name.ShouldBe("A");
import.From.ShouldBe("svc.foo");
b.Imports.Services.ShouldContainKey("svc.foo");
}
[Fact]
public void AddServiceImport_DirectCycle_Throws()
{
// A exports "svc.foo", B exports "svc.bar"
// B imports "svc.foo" from A (ok)
// A imports "svc.bar" from B — creates cycle A->B->A
var a = CreateAccount("A");
var b = CreateAccount("B");
SetupServiceExport(a, "svc.foo");
SetupServiceExport(b, "svc.bar");
b.AddServiceImport(a, "svc.foo", "svc.foo");
Should.Throw<InvalidOperationException>(() => a.AddServiceImport(b, "svc.bar", "svc.bar"))
.Message.ShouldContain("cycle");
}
[Fact]
public void AddServiceImport_IndirectCycle_A_B_C_A_Throws()
{
// A->B->C, then C->A creates indirect cycle
var a = CreateAccount("A");
var b = CreateAccount("B");
var c = CreateAccount("C");
SetupServiceExport(a, "svc.a");
SetupServiceExport(b, "svc.b");
SetupServiceExport(c, "svc.c");
// B imports from A
b.AddServiceImport(a, "svc.a", "svc.a");
// C imports from B
c.AddServiceImport(b, "svc.b", "svc.b");
// A imports from C — would create C->B->A->C cycle
Should.Throw<InvalidOperationException>(() => a.AddServiceImport(c, "svc.c", "svc.c"))
.Message.ShouldContain("cycle");
}
[Fact]
public void DetectCycle_NoCycle_ReturnsFalse()
{
var a = CreateAccount("A");
var b = CreateAccount("B");
var c = CreateAccount("C");
SetupServiceExport(a, "svc.a");
SetupServiceExport(b, "svc.b");
// A imports from B, B imports from C — linear chain, no cycle back to A
// For this test we manually add imports without cycle check via ImportMap
b.Imports.AddServiceImport(new ServiceImport
{
DestinationAccount = a,
From = "svc.a",
To = "svc.a",
});
// Check: does following imports from A lead back to C? No.
AccountImportExport.DetectCycle(a, c).ShouldBeFalse();
}
[Fact]
public void DetectCycle_DirectCycle_ReturnsTrue()
{
var a = CreateAccount("A");
var b = CreateAccount("B");
// A has import pointing to B
a.Imports.AddServiceImport(new ServiceImport
{
DestinationAccount = b,
From = "svc.x",
To = "svc.x",
});
// Does following from A lead to B? Yes.
AccountImportExport.DetectCycle(a, b).ShouldBeTrue();
}
[Fact]
public void DetectCycle_IndirectCycle_ReturnsTrue()
{
var a = CreateAccount("A");
var b = CreateAccount("B");
var c = CreateAccount("C");
// A -> B -> C (imports)
a.Imports.AddServiceImport(new ServiceImport
{
DestinationAccount = b,
From = "svc.1",
To = "svc.1",
});
b.Imports.AddServiceImport(new ServiceImport
{
DestinationAccount = c,
From = "svc.2",
To = "svc.2",
});
// Does following from A lead to C? Yes, via B.
AccountImportExport.DetectCycle(a, c).ShouldBeTrue();
}
[Fact]
public void RemoveServiceImport_ExistingImport_Succeeds()
{
var a = CreateAccount("A");
var b = CreateAccount("B");
SetupServiceExport(a, "svc.foo");
b.AddServiceImport(a, "svc.foo", "svc.foo");
b.Imports.Services.ShouldContainKey("svc.foo");
b.RemoveServiceImport("svc.foo").ShouldBeTrue();
b.Imports.Services.ShouldNotContainKey("svc.foo");
// Removing again returns false
b.RemoveServiceImport("svc.foo").ShouldBeFalse();
}
[Fact]
public void RemoveStreamImport_ExistingImport_Succeeds()
{
var a = CreateAccount("A");
var b = CreateAccount("B");
a.AddStreamExport("stream.data", null); // public
b.AddStreamImport(a, "stream.data", "imported.data");
b.Imports.Streams.Count.ShouldBe(1);
b.RemoveStreamImport("stream.data").ShouldBeTrue();
b.Imports.Streams.Count.ShouldBe(0);
// Removing again returns false
b.RemoveStreamImport("stream.data").ShouldBeFalse();
}
[Fact]
public void ValidateImport_UnauthorizedAccount_Throws()
{
var exporter = CreateAccount("Exporter");
var importer = CreateAccount("Importer");
var approved = CreateAccount("Approved");
// Export only approves "Approved" account, not "Importer"
SetupServiceExport(exporter, "svc.restricted", [approved]);
Should.Throw<UnauthorizedAccessException>(
() => AccountImportExport.ValidateImport(importer, exporter, "svc.restricted"))
.Message.ShouldContain("not authorized");
}
[Fact]
public void AddStreamImport_NoCycleCheck_Succeeds()
{
// Stream imports do not require cycle detection (unlike service imports).
// Even with a "circular" stream import topology, it should succeed.
var a = CreateAccount("A");
var b = CreateAccount("B");
a.AddStreamExport("stream.a", null);
b.AddStreamExport("stream.b", null);
// B imports stream from A
b.AddStreamImport(a, "stream.a", "imported.a");
// A imports stream from B — no cycle check for streams
a.AddStreamImport(b, "stream.b", "imported.b");
a.Imports.Streams.Count.ShouldBe(1);
b.Imports.Streams.Count.ShouldBe(1);
}
}

View File

@@ -0,0 +1,169 @@
// Tests for per-account JetStream resource limits.
// Go reference: accounts_test.go TestAccountLimits, TestJetStreamLimits.
using NATS.Server.Auth;
namespace NATS.Server.Auth.Tests.Auth;
public class AccountLimitsTests
{
[Fact]
public void TryReserveConsumer_UnderLimit_ReturnsTrue()
{
var account = new Account("test")
{
JetStreamLimits = new AccountLimits { MaxConsumers = 3 },
};
account.TryReserveConsumer().ShouldBeTrue();
account.TryReserveConsumer().ShouldBeTrue();
account.TryReserveConsumer().ShouldBeTrue();
account.ConsumerCount.ShouldBe(3);
}
[Fact]
public void TryReserveConsumer_AtLimit_ReturnsFalse()
{
var account = new Account("test")
{
JetStreamLimits = new AccountLimits { MaxConsumers = 2 },
};
account.TryReserveConsumer().ShouldBeTrue();
account.TryReserveConsumer().ShouldBeTrue();
account.TryReserveConsumer().ShouldBeFalse();
account.ConsumerCount.ShouldBe(2);
}
[Fact]
public void ReleaseConsumer_DecrementsCount()
{
var account = new Account("test")
{
JetStreamLimits = new AccountLimits { MaxConsumers = 2 },
};
account.TryReserveConsumer().ShouldBeTrue();
account.TryReserveConsumer().ShouldBeTrue();
account.ConsumerCount.ShouldBe(2);
account.ReleaseConsumer();
account.ConsumerCount.ShouldBe(1);
// Now we can reserve again
account.TryReserveConsumer().ShouldBeTrue();
account.ConsumerCount.ShouldBe(2);
}
[Fact]
public void TrackStorageDelta_UnderLimit_ReturnsTrue()
{
var account = new Account("test")
{
JetStreamLimits = new AccountLimits { MaxStorage = 1000 },
};
account.TrackStorageDelta(500).ShouldBeTrue();
account.StorageUsed.ShouldBe(500);
account.TrackStorageDelta(400).ShouldBeTrue();
account.StorageUsed.ShouldBe(900);
}
[Fact]
public void TrackStorageDelta_ExceedsLimit_ReturnsFalse()
{
var account = new Account("test")
{
JetStreamLimits = new AccountLimits { MaxStorage = 1000 },
};
account.TrackStorageDelta(800).ShouldBeTrue();
account.TrackStorageDelta(300).ShouldBeFalse(); // 800 + 300 = 1100 > 1000
account.StorageUsed.ShouldBe(800); // unchanged
}
[Fact]
public void TrackStorageDelta_NegativeDelta_ReducesUsage()
{
var account = new Account("test")
{
JetStreamLimits = new AccountLimits { MaxStorage = 1000 },
};
account.TrackStorageDelta(800).ShouldBeTrue();
account.TrackStorageDelta(-300).ShouldBeTrue(); // negative always succeeds
account.StorageUsed.ShouldBe(500);
// Now we have room again
account.TrackStorageDelta(400).ShouldBeTrue();
account.StorageUsed.ShouldBe(900);
}
[Fact]
public void MaxStorage_Zero_Unlimited()
{
var account = new Account("test")
{
JetStreamLimits = new AccountLimits { MaxStorage = 0 }, // unlimited
};
// Should accept any amount
account.TrackStorageDelta(long.MaxValue / 2).ShouldBeTrue();
account.StorageUsed.ShouldBe(long.MaxValue / 2);
}
[Fact]
public void Limits_DefaultValues_AllUnlimited()
{
var limits = AccountLimits.Unlimited;
limits.MaxStorage.ShouldBe(0);
limits.MaxStreams.ShouldBe(0);
limits.MaxConsumers.ShouldBe(0);
limits.MaxAckPending.ShouldBe(0);
limits.MaxMemoryStorage.ShouldBe(0);
limits.MaxDiskStorage.ShouldBe(0);
// Account defaults to unlimited
var account = new Account("test");
account.JetStreamLimits.ShouldBe(AccountLimits.Unlimited);
}
[Fact]
public void TryReserveStream_WithLimits_RespectsNewLimits()
{
// JetStreamLimits.MaxStreams should take precedence over MaxJetStreamStreams
var account = new Account("test")
{
MaxJetStreamStreams = 10, // legacy field
JetStreamLimits = new AccountLimits { MaxStreams = 2 }, // new limit overrides
};
account.TryReserveStream().ShouldBeTrue();
account.TryReserveStream().ShouldBeTrue();
account.TryReserveStream().ShouldBeFalse(); // limited to 2 by JetStreamLimits
account.JetStreamStreamCount.ShouldBe(2);
}
[Fact]
public void EvictOldestClient_WhenMaxConnectionsExceeded()
{
var account = new Account("test")
{
MaxConnections = 2,
};
account.AddClient(1).ShouldBeTrue();
account.AddClient(2).ShouldBeTrue();
account.AddClient(3).ShouldBeFalse(); // at limit
account.ClientCount.ShouldBe(2);
// Remove oldest, then new one can connect
account.RemoveClient(1);
account.ClientCount.ShouldBe(1);
account.AddClient(3).ShouldBeTrue();
account.ClientCount.ShouldBe(2);
}
}

View File

@@ -0,0 +1,117 @@
using NATS.Server.Auth;
using NATS.Server.Imports;
using NATS.Server.Subscriptions;
namespace NATS.Server.Auth.Tests.Auth;
public class AccountResponseAndInterestParityBatch1Tests
{
[Fact]
public void ClientInfoHdr_constant_matches_go_value()
{
Account.ClientInfoHdr.ShouldBe("Nats-Request-Info");
}
[Fact]
public void Interest_and_subscription_interest_count_plain_and_queue_matches()
{
using var account = new Account("A");
account.SubList.Insert(new Subscription { Subject = "orders.*", Sid = "1" });
account.SubList.Insert(new Subscription { Subject = "orders.*", Sid = "2", Queue = "workers" });
account.Interest("orders.created").ShouldBe(2);
account.SubscriptionInterest("orders.created").ShouldBeTrue();
account.SubscriptionInterest("payments.created").ShouldBeFalse();
}
[Fact]
public void NumServiceImports_counts_distinct_from_subject_keys()
{
using var importer = new Account("importer");
using var exporter = new Account("exporter");
importer.Imports.AddServiceImport(new ServiceImport
{
DestinationAccount = exporter,
From = "svc.a",
To = "svc.remote.a",
});
importer.Imports.AddServiceImport(new ServiceImport
{
DestinationAccount = exporter,
From = "svc.a",
To = "svc.remote.b",
});
importer.Imports.AddServiceImport(new ServiceImport
{
DestinationAccount = exporter,
From = "svc.b",
To = "svc.remote.c",
});
importer.NumServiceImports().ShouldBe(2);
}
[Fact]
public void NumPendingResponses_filters_by_service_export()
{
using var account = new Account("A");
account.AddServiceExport("svc.one", ServiceResponseType.Singleton, null);
account.AddServiceExport("svc.two", ServiceResponseType.Singleton, null);
var seOne = account.Exports.Services["svc.one"];
var seTwo = account.Exports.Services["svc.two"];
account.Exports.Responses["r1"] = new ServiceImport
{
DestinationAccount = account,
From = "_R_.AAA.>",
To = "reply.one",
Export = seOne,
IsResponse = true,
};
account.Exports.Responses["r2"] = new ServiceImport
{
DestinationAccount = account,
From = "_R_.BBB.>",
To = "reply.two",
Export = seOne,
IsResponse = true,
};
account.Exports.Responses["r3"] = new ServiceImport
{
DestinationAccount = account,
From = "_R_.CCC.>",
To = "reply.three",
Export = seTwo,
IsResponse = true,
};
account.NumPendingAllResponses().ShouldBe(3);
account.NumPendingResponses("svc.one").ShouldBe(2);
account.NumPendingResponses("svc.two").ShouldBe(1);
account.NumPendingResponses("svc.unknown").ShouldBe(0);
}
[Fact]
public void RemoveRespServiceImport_removes_mapping_for_specified_reason()
{
using var account = new Account("A");
account.AddServiceExport("svc.one", ServiceResponseType.Singleton, null);
var seOne = account.Exports.Services["svc.one"];
var responseSi = new ServiceImport
{
DestinationAccount = account,
From = "_R_.ZZZ.>",
To = "reply",
Export = seOne,
IsResponse = true,
};
account.Exports.Responses["r1"] = responseSi;
account.RemoveRespServiceImport(responseSi, ResponseServiceImportRemovalReason.Timeout);
account.Exports.Responses.Count.ShouldBe(0);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
// Tests for Account JWT activation claim expiration: RegisterActivation,
// CheckActivationExpiry, IsActivationExpired, GetExpiredActivations,
// RemoveExpiredActivations, and ActiveActivationCount.
// Go reference: server/accounts.go — checkActivation (~line 2943),
// activationExpired (~line 2920).
using NATS.Server.Auth;
namespace NATS.Server.Auth.Tests.Auth;
public class ActivationExpirationTests
{
// Helpers for well-past / well-future dates to avoid timing flakiness.
private static DateTime WellFuture => DateTime.UtcNow.AddDays(30);
private static DateTime WellPast => DateTime.UtcNow.AddDays(-30);
private static ActivationClaim ValidClaim(string subject) => new()
{
Subject = subject,
IssuedAt = DateTime.UtcNow.AddDays(-1),
ExpiresAt = WellFuture,
Issuer = "AABC123",
};
private static ActivationClaim ExpiredClaim(string subject) => new()
{
Subject = subject,
IssuedAt = DateTime.UtcNow.AddDays(-60),
ExpiresAt = WellPast,
Issuer = "AABC123",
};
// ---------------------------------------------------------------------------
// RegisterActivation
// ---------------------------------------------------------------------------
[Fact]
public void RegisterActivation_StoresActivation()
{
// Go ref: accounts.go — checkActivation stores the decoded activation claim.
var account = new Account("test");
var claim = ValidClaim("svc.foo");
account.RegisterActivation("svc.foo", claim);
var result = account.CheckActivationExpiry("svc.foo");
result.Found.ShouldBeTrue();
}
// ---------------------------------------------------------------------------
// CheckActivationExpiry
// ---------------------------------------------------------------------------
[Fact]
public void CheckActivationExpiry_Valid_NotExpired()
{
// Go ref: accounts.go — act.Expires > tn ⇒ checkActivation returns true (not expired).
var account = new Account("test");
account.RegisterActivation("svc.valid", ValidClaim("svc.valid"));
var result = account.CheckActivationExpiry("svc.valid");
result.Found.ShouldBeTrue();
result.IsExpired.ShouldBeFalse();
result.ExpiresAt.ShouldNotBeNull();
result.TimeToExpiry.ShouldNotBeNull();
result.TimeToExpiry!.Value.ShouldBeGreaterThan(TimeSpan.Zero);
}
[Fact]
public void CheckActivationExpiry_Expired_ReturnsExpired()
{
// Go ref: accounts.go — act.Expires <= tn ⇒ checkActivation returns false (expired).
var account = new Account("test");
account.RegisterActivation("svc.expired", ExpiredClaim("svc.expired"));
var result = account.CheckActivationExpiry("svc.expired");
result.Found.ShouldBeTrue();
result.IsExpired.ShouldBeTrue();
result.ExpiresAt.ShouldNotBeNull();
result.TimeToExpiry.ShouldBe(TimeSpan.Zero);
}
[Fact]
public void CheckActivationExpiry_NotFound()
{
// Go ref: accounts.go — checkActivation returns false when claim is nil/empty token.
var account = new Account("test");
var result = account.CheckActivationExpiry("svc.unknown");
result.Found.ShouldBeFalse();
result.IsExpired.ShouldBeFalse();
result.ExpiresAt.ShouldBeNull();
result.TimeToExpiry.ShouldBeNull();
}
// ---------------------------------------------------------------------------
// IsActivationExpired
// ---------------------------------------------------------------------------
[Fact]
public void IsActivationExpired_Valid_ReturnsFalse()
{
// Go ref: accounts.go — act.Expires > tn ⇒ not expired.
var account = new Account("test");
account.RegisterActivation("svc.ok", ValidClaim("svc.ok"));
account.IsActivationExpired("svc.ok").ShouldBeFalse();
}
[Fact]
public void IsActivationExpired_Expired_ReturnsTrue()
{
// Go ref: accounts.go — act.Expires <= tn ⇒ expired, activationExpired fires.
var account = new Account("test");
account.RegisterActivation("svc.past", ExpiredClaim("svc.past"));
account.IsActivationExpired("svc.past").ShouldBeTrue();
}
// ---------------------------------------------------------------------------
// GetExpiredActivations
// ---------------------------------------------------------------------------
[Fact]
public void GetExpiredActivations_ReturnsOnlyExpired()
{
// Go ref: accounts.go — activationExpired is called only for expired claims.
var account = new Account("test");
account.RegisterActivation("svc.a", ValidClaim("svc.a"));
account.RegisterActivation("svc.b", ExpiredClaim("svc.b"));
account.RegisterActivation("svc.c", ValidClaim("svc.c"));
account.RegisterActivation("svc.d", ExpiredClaim("svc.d"));
var expired = account.GetExpiredActivations();
expired.Count.ShouldBe(2);
expired.ShouldContain("svc.b");
expired.ShouldContain("svc.d");
expired.ShouldNotContain("svc.a");
expired.ShouldNotContain("svc.c");
}
// ---------------------------------------------------------------------------
// RemoveExpiredActivations
// ---------------------------------------------------------------------------
[Fact]
public void RemoveExpiredActivations_RemovesAndReturnsCount()
{
// Go ref: accounts.go — activationExpired removes the import when activation expires.
var account = new Account("test");
account.RegisterActivation("svc.live", ValidClaim("svc.live"));
account.RegisterActivation("svc.gone1", ExpiredClaim("svc.gone1"));
account.RegisterActivation("svc.gone2", ExpiredClaim("svc.gone2"));
var removed = account.RemoveExpiredActivations();
removed.ShouldBe(2);
// The expired ones should no longer be found.
account.CheckActivationExpiry("svc.gone1").Found.ShouldBeFalse();
account.CheckActivationExpiry("svc.gone2").Found.ShouldBeFalse();
// The live one should still be registered.
account.CheckActivationExpiry("svc.live").Found.ShouldBeTrue();
}
// ---------------------------------------------------------------------------
// ActiveActivationCount
// ---------------------------------------------------------------------------
[Fact]
public void ActiveActivationCount_ExcludesExpired()
{
// Go ref: accounts.go — only non-expired activations are considered active.
var account = new Account("test");
account.RegisterActivation("svc.1", ValidClaim("svc.1"));
account.RegisterActivation("svc.2", ValidClaim("svc.2"));
account.RegisterActivation("svc.3", ExpiredClaim("svc.3"));
account.ActiveActivationCount.ShouldBe(2);
}
// ---------------------------------------------------------------------------
// ActivationClaim.TimeToExpiry
// ---------------------------------------------------------------------------
[Fact]
public void ActivationClaim_TimeToExpiry_Zero_WhenExpired()
{
// Go ref: accounts.go — expired activation has no remaining time.
var claim = ExpiredClaim("svc.expired");
claim.IsExpired.ShouldBeTrue();
claim.TimeToExpiry.ShouldBe(TimeSpan.Zero);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Auth.Tests;
public class AuthExtensionParityTests
{
[Fact]
public void Auth_service_uses_proxy_auth_extension_when_enabled()
{
var service = AuthService.Build(new NatsOptions
{
ProxyAuth = new ProxyAuthOptions
{
Enabled = true,
UsernamePrefix = "proxy:",
},
});
service.IsAuthRequired.ShouldBeTrue();
var result = service.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "proxy:alice" },
Nonce = [],
});
result.ShouldNotBeNull();
result.Identity.ShouldBe("alice");
}
}

View File

@@ -0,0 +1,46 @@
using NATS.Server.Auth;
namespace NATS.Server.Auth.Tests.Auth;
public class AuthModelAndCalloutConstantsParityTests
{
[Fact]
public void NkeyUser_exposes_parity_fields()
{
var now = DateTimeOffset.UtcNow;
var nkeyUser = new NKeyUser
{
Nkey = "UABC",
Issued = now,
AllowedConnectionTypes = new HashSet<string> { "STANDARD", "WEBSOCKET" },
ProxyRequired = true,
};
nkeyUser.Issued.ShouldBe(now);
nkeyUser.ProxyRequired.ShouldBeTrue();
nkeyUser.AllowedConnectionTypes.ShouldContain("STANDARD");
}
[Fact]
public void User_exposes_parity_fields()
{
var user = new User
{
Username = "alice",
Password = "secret",
AllowedConnectionTypes = new HashSet<string> { "STANDARD" },
ProxyRequired = false,
};
user.ProxyRequired.ShouldBeFalse();
user.AllowedConnectionTypes.ShouldContain("STANDARD");
}
[Fact]
public void External_auth_callout_constants_match_go_subjects_and_header()
{
ExternalAuthCalloutAuthenticator.AuthCalloutSubject.ShouldBe("$SYS.REQ.USER.AUTH");
ExternalAuthCalloutAuthenticator.AuthRequestSubject.ShouldBe("nats-authorization-request");
ExternalAuthCalloutAuthenticator.AuthRequestXKeyHeader.ShouldBe("Nats-Server-Xkey");
}
}

View File

@@ -0,0 +1,89 @@
using NATS.NKeys;
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Auth.Tests.Auth;
public class AuthServiceParityBatch4Tests
{
[Fact]
public void Build_assigns_global_account_to_orphan_users()
{
var service = AuthService.Build(new NatsOptions
{
Users = [new User { Username = "alice", Password = "secret" }],
});
var result = service.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "alice", Password = "secret" },
Nonce = [],
});
result.ShouldNotBeNull();
result.AccountName.ShouldBe(Account.GlobalAccountName);
}
[Fact]
public void Build_assigns_global_account_to_orphan_nkeys()
{
using var kp = KeyPair.CreatePair(PrefixByte.User);
var pub = kp.GetPublicKey();
var nonce = "test-nonce"u8.ToArray();
var sig = new byte[64];
kp.Sign(nonce, sig);
var service = AuthService.Build(new NatsOptions
{
NKeys = [new NKeyUser { Nkey = pub }],
});
var result = service.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions
{
Nkey = pub,
Sig = Convert.ToBase64String(sig),
},
Nonce = nonce,
});
result.ShouldNotBeNull();
result.AccountName.ShouldBe(Account.GlobalAccountName);
}
[Fact]
public void Build_validates_response_permissions_defaults_and_publish_allow()
{
var service = AuthService.Build(new NatsOptions
{
Users =
[
new User
{
Username = "alice",
Password = "secret",
Permissions = new Permissions
{
Response = new ResponsePermission { MaxMsgs = 0, Expires = TimeSpan.Zero },
},
},
],
});
var result = service.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "alice", Password = "secret" },
Nonce = [],
});
result.ShouldNotBeNull();
result.Permissions.ShouldNotBeNull();
result.Permissions.Response.ShouldNotBeNull();
result.Permissions.Response.MaxMsgs.ShouldBe(NatsProtocol.DefaultAllowResponseMaxMsgs);
result.Permissions.Response.Expires.ShouldBe(NatsProtocol.DefaultAllowResponseExpiration);
result.Permissions.Publish.ShouldNotBeNull();
result.Permissions.Publish.Allow.ShouldNotBeNull();
result.Permissions.Publish.Allow.Count.ShouldBe(0);
}
}

View File

@@ -0,0 +1,61 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Auth.Tests;
public class ExternalAuthCalloutTests
{
[Fact]
public void External_callout_authenticator_can_allow_and_deny_with_timeout_and_reason_mapping()
{
var authenticator = new ExternalAuthCalloutAuthenticator(
new FakeExternalAuthClient(),
TimeSpan.FromMilliseconds(50));
var allowed = authenticator.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "u", Password = "p" },
Nonce = [],
});
allowed.ShouldNotBeNull();
allowed.Identity.ShouldBe("u");
var denied = authenticator.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "u", Password = "bad" },
Nonce = [],
});
denied.ShouldBeNull();
var timeout = new ExternalAuthCalloutAuthenticator(
new SlowExternalAuthClient(TimeSpan.FromMilliseconds(200)),
TimeSpan.FromMilliseconds(30));
timeout.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "u", Password = "p" },
Nonce = [],
}).ShouldBeNull();
}
private sealed class FakeExternalAuthClient : IExternalAuthClient
{
public Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
{
if (request is { Username: "u", Password: "p" })
return Task.FromResult(new ExternalAuthDecision(true, "u", "A"));
return Task.FromResult(new ExternalAuthDecision(false, Reason: "denied"));
}
}
private sealed class SlowExternalAuthClient(TimeSpan delay) : IExternalAuthClient
{
public async Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
{
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var reg = ct.Register(() => tcs.TrySetCanceled(ct));
using var timer = new Timer(_ => tcs.TrySetResult(true), null, delay, Timeout.InfiniteTimeSpan);
await tcs.Task;
return new ExternalAuthDecision(true, "slow");
}
}
}

View File

@@ -0,0 +1,170 @@
// Tests for service import shadowing detection.
// Go reference: accounts.go serviceImportShadowed (~line 2015).
using NATS.Server.Auth;
using NATS.Server.Imports;
using NATS.Server.Subscriptions;
namespace NATS.Server.Auth.Tests.Auth;
public class ImportShadowingTests
{
private static Account CreateAccount(string name) => new(name);
private static Subscription MakeSub(string subject) =>
new() { Subject = subject, Sid = subject };
/// <summary>
/// Adds a service import entry directly to the account's import map (bypassing
/// export/cycle checks) so that shadowing tests can exercise the import map iteration.
/// </summary>
private static void RegisterServiceImport(Account account, string fromSubject)
{
var dest = CreateAccount("Dest");
var si = new ServiceImport
{
DestinationAccount = dest,
From = fromSubject,
To = fromSubject,
};
account.Imports.AddServiceImport(si);
}
// Go reference: accounts.go serviceImportShadowed (~line 2015).
[Fact]
public void ServiceImportShadowed_NoLocalSubs_ReturnsFalse()
{
var account = CreateAccount("A");
var result = account.ServiceImportShadowed("orders.create");
result.ShouldBeFalse();
}
// Go reference: accounts.go serviceImportShadowed (~line 2015).
[Fact]
public void ServiceImportShadowed_ExactMatch_ReturnsTrue()
{
var account = CreateAccount("A");
account.SubList.Insert(MakeSub("orders.create"));
var result = account.ServiceImportShadowed("orders.create");
result.ShouldBeTrue();
}
// Go reference: accounts.go serviceImportShadowed (~line 2015).
[Fact]
public void ServiceImportShadowed_WildcardMatch_ReturnsTrue()
{
// Local subscription "orders.*" shadows import on "orders.create"
var account = CreateAccount("A");
account.SubList.Insert(MakeSub("orders.*"));
var result = account.ServiceImportShadowed("orders.create");
result.ShouldBeTrue();
}
// Go reference: accounts.go serviceImportShadowed (~line 2015).
[Fact]
public void ServiceImportShadowed_GtWildcard_ReturnsTrue()
{
// Local subscription "orders.>" shadows import on "orders.create.new"
var account = CreateAccount("A");
account.SubList.Insert(MakeSub("orders.>"));
var result = account.ServiceImportShadowed("orders.create.new");
result.ShouldBeTrue();
}
// Go reference: accounts.go serviceImportShadowed (~line 2015).
[Fact]
public void ServiceImportShadowed_NoMatch_ReturnsFalse()
{
// Local subscription "users.*" does NOT shadow import on "orders.create"
var account = CreateAccount("A");
account.SubList.Insert(MakeSub("users.*"));
var result = account.ServiceImportShadowed("orders.create");
result.ShouldBeFalse();
}
// Go reference: accounts.go serviceImportShadowed (~line 2015).
[Fact]
public void GetShadowedServiceImports_ReturnsOnlyShadowed()
{
var account = CreateAccount("A");
// Register two service imports
RegisterServiceImport(account, "orders.create");
RegisterServiceImport(account, "users.profile");
// Only add a local sub that shadows "orders.create"
account.SubList.Insert(MakeSub("orders.create"));
var shadowed = account.GetShadowedServiceImports();
shadowed.Count.ShouldBe(1);
shadowed.ShouldContain("orders.create");
shadowed.ShouldNotContain("users.profile");
}
// Go reference: accounts.go serviceImportShadowed (~line 2015).
[Fact]
public void HasShadowedImports_True_WhenShadowed()
{
var account = CreateAccount("A");
RegisterServiceImport(account, "orders.create");
account.SubList.Insert(MakeSub("orders.create"));
account.HasShadowedImports.ShouldBeTrue();
}
// Go reference: accounts.go serviceImportShadowed (~line 2015).
[Fact]
public void HasShadowedImports_False_WhenNone()
{
var account = CreateAccount("A");
RegisterServiceImport(account, "orders.create");
// No local subs — nothing shadows the import
account.HasShadowedImports.ShouldBeFalse();
}
// Go reference: accounts.go serviceImportShadowed (~line 2015).
[Fact]
public void CheckServiceImportShadowing_ReturnsShadowingSubscriptions()
{
var account = CreateAccount("A");
account.SubList.Insert(MakeSub("orders.*"));
account.SubList.Insert(MakeSub("orders.>"));
var result = account.CheckServiceImportShadowing("orders.create");
result.IsShadowed.ShouldBeTrue();
result.ImportSubject.ShouldBe("orders.create");
result.ShadowingSubscriptions.Count.ShouldBeGreaterThan(0);
// Both wildcard subs match "orders.create"
result.ShadowingSubscriptions.ShouldContain("orders.*");
result.ShadowingSubscriptions.ShouldContain("orders.>");
}
// Go reference: accounts.go serviceImportShadowed (~line 2015).
[Fact]
public void CheckServiceImportShadowing_NotShadowed()
{
var account = CreateAccount("A");
account.SubList.Insert(MakeSub("users.*"));
var result = account.CheckServiceImportShadowing("orders.create");
result.IsShadowed.ShouldBeFalse();
result.ImportSubject.ShouldBe("orders.create");
result.ShadowingSubscriptions.Count.ShouldBe(0);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,165 @@
// Tests for user NKey revocation on Account.
// Go reference: accounts_test.go TestJWTUserRevocation, checkUserRevoked (~line 3202),
// isRevoked with jwt.All global key (~line 2929).
using NATS.Server.Auth;
namespace NATS.Server.Auth.Tests.Auth;
public class NKeyRevocationTests
{
// ── 1 ──────────────────────────────────────────────────────────────────────
[Fact]
public void RevokeUser_AddsToRevokedList()
{
var account = new Account("A");
account.RevokeUser("UNKEY1", 100L);
account.RevokedUserCount.ShouldBe(1);
}
// ── 2 ──────────────────────────────────────────────────────────────────────
[Fact]
public void IsUserRevoked_Revoked_ReturnsTrue()
{
// A JWT issued at t=50 revoked when the revocation timestamp is 100
// means issuedAt (50) <= revokedAt (100) → revoked.
// Go reference: accounts.go isRevoked — t < issuedAt ⇒ NOT revoked (inverted).
var account = new Account("A");
account.RevokeUser("UNKEY1", 100L);
account.IsUserRevoked("UNKEY1", 50L).ShouldBeTrue();
}
// ── 3 ──────────────────────────────────────────────────────────────────────
[Fact]
public void IsUserRevoked_NotRevoked_ReturnsFalse()
{
// A JWT issued at t=200 with revocation timestamp 100 means
// issuedAt (200) > revokedAt (100) → NOT revoked.
var account = new Account("A");
account.RevokeUser("UNKEY1", 100L);
account.IsUserRevoked("UNKEY1", 200L).ShouldBeFalse();
}
// ── 4 ──────────────────────────────────────────────────────────────────────
[Fact]
public void RevokedUserCount_MatchesRevocations()
{
var account = new Account("A");
account.RevokedUserCount.ShouldBe(0);
account.RevokeUser("UNKEY1", 1L);
account.RevokedUserCount.ShouldBe(1);
account.RevokeUser("UNKEY2", 2L);
account.RevokedUserCount.ShouldBe(2);
// Revoking the same key again does not increase count.
account.RevokeUser("UNKEY1", 99L);
account.RevokedUserCount.ShouldBe(2);
}
// ── 5 ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetRevokedUsers_ReturnsAllKeys()
{
var account = new Account("A");
account.RevokeUser("UNKEY1", 1L);
account.RevokeUser("UNKEY2", 2L);
account.RevokeUser("UNKEY3", 3L);
var keys = account.GetRevokedUsers();
keys.Count.ShouldBe(3);
keys.ShouldContain("UNKEY1");
keys.ShouldContain("UNKEY2");
keys.ShouldContain("UNKEY3");
}
// ── 6 ──────────────────────────────────────────────────────────────────────
[Fact]
public void UnrevokeUser_RemovesRevocation()
{
var account = new Account("A");
account.RevokeUser("UNKEY1", 100L);
account.RevokedUserCount.ShouldBe(1);
var removed = account.UnrevokeUser("UNKEY1");
removed.ShouldBeTrue();
account.RevokedUserCount.ShouldBe(0);
account.IsUserRevoked("UNKEY1", 50L).ShouldBeFalse();
}
// ── 7 ──────────────────────────────────────────────────────────────────────
[Fact]
public void UnrevokeUser_NonExistent_ReturnsFalse()
{
var account = new Account("A");
var removed = account.UnrevokeUser("DOES_NOT_EXIST");
removed.ShouldBeFalse();
account.RevokedUserCount.ShouldBe(0);
}
// ── 8 ──────────────────────────────────────────────────────────────────────
[Fact]
public void ClearAllRevocations_EmptiesList()
{
var account = new Account("A");
account.RevokeUser("UNKEY1", 1L);
account.RevokeUser("UNKEY2", 2L);
account.RevokeAllUsers(999L);
account.RevokedUserCount.ShouldBe(3);
account.ClearAllRevocations();
account.RevokedUserCount.ShouldBe(0);
account.GetRevokedUsers().ShouldBeEmpty();
account.IsGlobalRevocation().ShouldBeFalse();
}
// ── 9 ──────────────────────────────────────────────────────────────────────
[Fact]
public void RevokeAllUsers_SetsGlobalRevocation()
{
// Go reference: accounts.go — Revocations[jwt.All] used in isRevoked (~line 2934).
// The "*" key causes any user whose issuedAt <= timestamp to be revoked.
var account = new Account("A");
account.RevokeAllUsers(500L);
account.IsGlobalRevocation().ShouldBeTrue();
// User issued at 500 is revoked (≤ 500).
account.IsUserRevoked("ANY_USER", 500L).ShouldBeTrue();
// User issued at 499 is also revoked.
account.IsUserRevoked("ANY_USER", 499L).ShouldBeTrue();
// User issued at 501 is NOT revoked (> 500).
account.IsUserRevoked("ANY_USER", 501L).ShouldBeFalse();
}
// ── 10 ─────────────────────────────────────────────────────────────────────
[Fact]
public void GetRevocationInfo_ReturnsComplete()
{
var account = new Account("A");
account.RevokeUser("UNKEY1", 10L);
account.RevokeUser("UNKEY2", 20L);
account.RevokeAllUsers(999L);
var info = account.GetRevocationInfo();
// Two per-user keys + one global "*" key = 3 total.
info.RevokedCount.ShouldBe(3);
info.HasGlobalRevocation.ShouldBeTrue();
info.RevokedNKeys.Count.ShouldBe(3);
info.RevokedNKeys.ShouldContain("UNKEY1");
info.RevokedNKeys.ShouldContain("UNKEY2");
info.RevokedNKeys.ShouldContain("*");
}
}

View File

@@ -0,0 +1,28 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.Auth.Tests;
public class ProxyAuthTests
{
[Fact]
public void Proxy_authenticator_maps_prefixed_username_to_identity()
{
var authenticator = new ProxyAuthenticator(new ProxyAuthOptions
{
Enabled = true,
UsernamePrefix = "proxy:",
Account = "A",
});
var result = authenticator.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "proxy:bob" },
Nonce = [],
});
result.ShouldNotBeNull();
result.Identity.ShouldBe("bob");
result.AccountName.ShouldBe("A");
}
}

View File

@@ -0,0 +1,137 @@
// Tests for Account.SetServiceResponseThreshold / GetServiceResponseThreshold /
// IsServiceResponseOverdue / CheckServiceResponse.
// Go reference: server/accounts.go — SetServiceExportResponseThreshold (~line 2522),
// ServiceExportResponseThreshold (~line 2510).
using NATS.Server.Auth;
namespace NATS.Server.Auth.Tests.Auth;
public class ResponseThresholdTests
{
// ---------------------------------------------------------------------------
// SetServiceResponseThreshold / GetServiceResponseThreshold
// ---------------------------------------------------------------------------
[Fact]
public void SetServiceResponseThreshold_StoresThreshold()
{
// Go ref: accounts.go SetServiceExportResponseThreshold (~line 2522)
var account = new Account("test");
account.SetServiceResponseThreshold("svc.foo", TimeSpan.FromSeconds(5));
account.ServiceResponseThresholds.ContainsKey("svc.foo").ShouldBeTrue();
account.ServiceResponseThresholds["svc.foo"].ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void GetServiceResponseThreshold_ReturnsStored()
{
// Go ref: accounts.go ServiceExportResponseThreshold (~line 2510)
var account = new Account("test");
account.SetServiceResponseThreshold("svc.bar", TimeSpan.FromMilliseconds(200));
account.GetServiceResponseThreshold("svc.bar").ShouldBe(TimeSpan.FromMilliseconds(200));
}
[Fact]
public void GetServiceResponseThreshold_NotSet_ReturnsNull()
{
// Go ref: accounts.go ServiceExportResponseThreshold — returns error when export not found
var account = new Account("test");
account.GetServiceResponseThreshold("svc.unknown").ShouldBeNull();
}
// ---------------------------------------------------------------------------
// IsServiceResponseOverdue
// ---------------------------------------------------------------------------
[Fact]
public void IsServiceResponseOverdue_WithinThreshold_ReturnsFalse()
{
// Go ref: accounts.go respThresh check — elapsed < threshold ⇒ not overdue
var account = new Account("test");
account.SetServiceResponseThreshold("svc.a", TimeSpan.FromSeconds(10));
account.IsServiceResponseOverdue("svc.a", TimeSpan.FromSeconds(9)).ShouldBeFalse();
}
[Fact]
public void IsServiceResponseOverdue_ExceedsThreshold_ReturnsTrue()
{
// Go ref: accounts.go respThresh check — elapsed > threshold ⇒ overdue
var account = new Account("test");
account.SetServiceResponseThreshold("svc.b", TimeSpan.FromSeconds(1));
account.IsServiceResponseOverdue("svc.b", TimeSpan.FromSeconds(2)).ShouldBeTrue();
}
[Fact]
public void IsServiceResponseOverdue_NoThreshold_ReturnsFalse()
{
// Go ref: accounts.go — when no respThresh is set the timer never fires (never overdue)
var account = new Account("test");
account.IsServiceResponseOverdue("svc.unregistered", TimeSpan.FromHours(1)).ShouldBeFalse();
}
[Fact]
public void SetServiceResponseThreshold_OverwritesPrevious()
{
// Go ref: accounts.go SetServiceExportResponseThreshold — se.respThresh = maxTime overwrites
var account = new Account("test");
account.SetServiceResponseThreshold("svc.c", TimeSpan.FromSeconds(5));
account.SetServiceResponseThreshold("svc.c", TimeSpan.FromSeconds(30));
account.GetServiceResponseThreshold("svc.c").ShouldBe(TimeSpan.FromSeconds(30));
}
// ---------------------------------------------------------------------------
// CheckServiceResponse
// ---------------------------------------------------------------------------
[Fact]
public void CheckServiceResponse_Found_NotOverdue()
{
// Go ref: accounts.go ServiceExportResponseThreshold + respThresh timer — within window
var account = new Account("test");
account.SetServiceResponseThreshold("svc.d", TimeSpan.FromSeconds(10));
var result = account.CheckServiceResponse("svc.d", TimeSpan.FromSeconds(5));
result.Found.ShouldBeTrue();
result.IsOverdue.ShouldBeFalse();
result.Threshold.ShouldBe(TimeSpan.FromSeconds(10));
result.Elapsed.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void CheckServiceResponse_Found_Overdue()
{
// Go ref: accounts.go respThresh timer fires — elapsed exceeded threshold
var account = new Account("test");
account.SetServiceResponseThreshold("svc.e", TimeSpan.FromSeconds(2));
var result = account.CheckServiceResponse("svc.e", TimeSpan.FromSeconds(5));
result.Found.ShouldBeTrue();
result.IsOverdue.ShouldBeTrue();
result.Threshold.ShouldBe(TimeSpan.FromSeconds(2));
result.Elapsed.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void CheckServiceResponse_NotFound()
{
// Go ref: accounts.go — no export defined, returns error; here Found=false
var account = new Account("test");
var result = account.CheckServiceResponse("svc.none", TimeSpan.FromSeconds(1));
result.Found.ShouldBeFalse();
result.IsOverdue.ShouldBeFalse();
result.Threshold.ShouldBeNull();
result.Elapsed.ShouldBe(TimeSpan.FromSeconds(1));
}
}

View File

@@ -0,0 +1,174 @@
// Tests for Account.AddReverseRespMapEntry / CheckForReverseEntries and related helpers.
// Go reference: server/accounts.go — addRespMapEntry (~line 2800), checkForReverseEntries (~line 2810).
using NATS.Server.Auth;
namespace NATS.Server.Auth.Tests.Auth;
public class ReverseResponseMapTests
{
// ---------------------------------------------------------------------------
// AddReverseRespMapEntry / CheckForReverseEntries
// ---------------------------------------------------------------------------
[Fact]
public void AddReverseRespMapEntry_StoresEntry()
{
// Go ref: accounts.go addRespMapEntry — stores respMapEntry keyed by rewritten reply
var account = new Account("A");
account.AddReverseRespMapEntry("_R_.abc", "B", "reply.1");
account.ReverseResponseMapCount.ShouldBe(1);
}
[Fact]
public void CheckForReverseEntries_Found_ReturnsEntry()
{
// Go ref: accounts.go checkForReverseEntries — returns entry when key exists
var account = new Account("A");
account.AddReverseRespMapEntry("_R_.xyz", "origin-account", "original.reply.subject");
var entry = account.CheckForReverseEntries("_R_.xyz");
entry.ShouldNotBeNull();
entry.ReplySubject.ShouldBe("_R_.xyz");
entry.OriginAccount.ShouldBe("origin-account");
entry.OriginalReply.ShouldBe("original.reply.subject");
}
[Fact]
public void CheckForReverseEntries_NotFound_ReturnsNull()
{
// Go ref: accounts.go checkForReverseEntries — returns nil when key absent
var account = new Account("A");
var entry = account.CheckForReverseEntries("_R_.nonexistent");
entry.ShouldBeNull();
}
// ---------------------------------------------------------------------------
// RemoveReverseRespMapEntry
// ---------------------------------------------------------------------------
[Fact]
public void RemoveReverseRespMapEntry_Found_ReturnsTrue()
{
// Go ref: accounts.go — reverse map cleanup after response is routed
var account = new Account("A");
account.AddReverseRespMapEntry("_R_.del", "B", "orig.reply");
var removed = account.RemoveReverseRespMapEntry("_R_.del");
removed.ShouldBeTrue();
account.ReverseResponseMapCount.ShouldBe(0);
}
[Fact]
public void RemoveReverseRespMapEntry_NotFound_ReturnsFalse()
{
// Go ref: accounts.go — removing an absent entry is a no-op
var account = new Account("A");
var removed = account.RemoveReverseRespMapEntry("_R_.missing");
removed.ShouldBeFalse();
}
// ---------------------------------------------------------------------------
// ReverseResponseMapCount
// ---------------------------------------------------------------------------
[Fact]
public void ReverseResponseMapCount_MatchesEntries()
{
// Go ref: accounts.go — map length reflects outstanding response mappings
var account = new Account("A");
account.ReverseResponseMapCount.ShouldBe(0);
account.AddReverseRespMapEntry("_R_.1", "B", "r1");
account.AddReverseRespMapEntry("_R_.2", "C", "r2");
account.AddReverseRespMapEntry("_R_.3", "D", "r3");
account.ReverseResponseMapCount.ShouldBe(3);
account.RemoveReverseRespMapEntry("_R_.2");
account.ReverseResponseMapCount.ShouldBe(2);
}
// ---------------------------------------------------------------------------
// ClearReverseResponseMap
// ---------------------------------------------------------------------------
[Fact]
public void ClearReverseResponseMap_EmptiesAll()
{
// Go ref: accounts.go — clearing map after bulk expiry / account teardown
var account = new Account("A");
account.AddReverseRespMapEntry("_R_.a", "B", "ra");
account.AddReverseRespMapEntry("_R_.b", "C", "rb");
account.ClearReverseResponseMap();
account.ReverseResponseMapCount.ShouldBe(0);
account.CheckForReverseEntries("_R_.a").ShouldBeNull();
account.CheckForReverseEntries("_R_.b").ShouldBeNull();
}
// ---------------------------------------------------------------------------
// GetReverseResponseMapKeys
// ---------------------------------------------------------------------------
[Fact]
public void GetReverseResponseMapKeys_ReturnsAllKeys()
{
// Go ref: accounts.go — iterating active respMapEntry keys for diagnostics
var account = new Account("A");
account.AddReverseRespMapEntry("_R_.k1", "B", "r1");
account.AddReverseRespMapEntry("_R_.k2", "C", "r2");
var keys = account.GetReverseResponseMapKeys();
keys.Count.ShouldBe(2);
keys.ShouldContain("_R_.k1");
keys.ShouldContain("_R_.k2");
}
// ---------------------------------------------------------------------------
// Overwrite and CreatedAt preservation
// ---------------------------------------------------------------------------
[Fact]
public void AddReverseRespMapEntry_OverwritesPrevious()
{
// Go ref: accounts.go addRespMapEntry — map assignment overwrites existing key
var account = new Account("A");
account.AddReverseRespMapEntry("_R_.ov", "B", "first.reply");
account.AddReverseRespMapEntry("_R_.ov", "C", "second.reply");
var entry = account.CheckForReverseEntries("_R_.ov");
entry.ShouldNotBeNull();
entry.OriginAccount.ShouldBe("C");
entry.OriginalReply.ShouldBe("second.reply");
account.ReverseResponseMapCount.ShouldBe(1);
}
[Fact]
public void ReverseRespMapEntry_PreservesCreatedAt()
{
// Go ref: accounts.go respMapEntry — timestamp recorded at map insertion time
var before = DateTime.UtcNow;
var account = new Account("A");
account.AddReverseRespMapEntry("_R_.ts", "B", "ts.reply");
var after = DateTime.UtcNow;
var entry = account.CheckForReverseEntries("_R_.ts");
entry.ShouldNotBeNull();
entry.CreatedAt.ShouldBeGreaterThanOrEqualTo(before);
entry.CreatedAt.ShouldBeLessThanOrEqualTo(after);
}
}

View File

@@ -0,0 +1,168 @@
// Tests for service export latency tracker with p50/p90/p99 percentile histogram.
// Go reference: accounts_test.go TestServiceLatency, serviceExportLatencyStats.
using NATS.Server.Auth;
namespace NATS.Server.Auth.Tests.Auth;
public class ServiceLatencyTrackerTests
{
[Fact]
public void RecordLatency_IncrementsTotalRequests()
{
var tracker = new ServiceLatencyTracker();
tracker.RecordLatency(10.0);
tracker.RecordLatency(20.0);
tracker.RecordLatency(30.0);
tracker.TotalRequests.ShouldBe(3L);
}
[Fact]
public void GetP50_ReturnsMedian()
{
var tracker = new ServiceLatencyTracker();
foreach (var v in new double[] { 1, 2, 3, 4, 5 })
tracker.RecordLatency(v);
// Sorted: [1, 2, 3, 4, 5], index = (int)(0.50 * 4) = 2 → value 3
tracker.GetP50().ShouldBe(3.0);
}
[Fact]
public void GetP90_ReturnsHighPercentile()
{
var tracker = new ServiceLatencyTracker();
for (var i = 1; i <= 100; i++)
tracker.RecordLatency(i);
// Sorted [1..100], index = (int)(0.90 * 99) = (int)89.1 = 89 → value 90
tracker.GetP90().ShouldBe(90.0);
}
[Fact]
public void GetP99_ReturnsTopPercentile()
{
var tracker = new ServiceLatencyTracker();
for (var i = 1; i <= 100; i++)
tracker.RecordLatency(i);
// Sorted [1..100], index = (int)(0.99 * 99) = (int)98.01 = 98 → value 99
tracker.GetP99().ShouldBe(99.0);
}
[Fact]
public void AverageLatencyMs_CalculatesCorrectly()
{
var tracker = new ServiceLatencyTracker();
tracker.RecordLatency(10.0);
tracker.RecordLatency(20.0);
tracker.RecordLatency(30.0);
tracker.AverageLatencyMs.ShouldBe(20.0);
}
[Fact]
public void MinLatencyMs_ReturnsMinimum()
{
var tracker = new ServiceLatencyTracker();
tracker.RecordLatency(15.0);
tracker.RecordLatency(5.0);
tracker.RecordLatency(10.0);
tracker.MinLatencyMs.ShouldBe(5.0);
}
[Fact]
public void MaxLatencyMs_ReturnsMaximum()
{
var tracker = new ServiceLatencyTracker();
tracker.RecordLatency(5.0);
tracker.RecordLatency(15.0);
tracker.RecordLatency(10.0);
tracker.MaxLatencyMs.ShouldBe(15.0);
}
[Fact]
public void Reset_ClearsSamples()
{
var tracker = new ServiceLatencyTracker();
tracker.RecordLatency(10.0);
tracker.RecordLatency(20.0);
tracker.SampleCount.ShouldBe(2);
tracker.TotalRequests.ShouldBe(2L);
tracker.Reset();
tracker.SampleCount.ShouldBe(0);
tracker.TotalRequests.ShouldBe(0L);
tracker.AverageLatencyMs.ShouldBe(0.0);
tracker.MinLatencyMs.ShouldBe(0.0);
tracker.MaxLatencyMs.ShouldBe(0.0);
tracker.GetP50().ShouldBe(0.0);
}
[Fact]
public void GetSnapshot_ReturnsImmutableSnapshot()
{
var tracker = new ServiceLatencyTracker();
tracker.RecordLatency(10.0);
tracker.RecordLatency(20.0);
tracker.RecordLatency(30.0);
var snapshot = tracker.GetSnapshot();
snapshot.TotalRequests.ShouldBe(3L);
snapshot.SampleCount.ShouldBe(3);
snapshot.AverageMs.ShouldBe(20.0);
snapshot.MinMs.ShouldBe(10.0);
snapshot.MaxMs.ShouldBe(30.0);
// P50 of [10, 20, 30]: index = (int)(0.50 * 2) = 1 → 20
snapshot.P50Ms.ShouldBe(20.0);
// Mutating tracker after snapshot does not change the snapshot
tracker.RecordLatency(1000.0);
snapshot.MaxMs.ShouldBe(30.0);
snapshot.SampleCount.ShouldBe(3);
}
[Fact]
public void MaxSamples_EvictsOldest()
{
var tracker = new ServiceLatencyTracker(maxSamples: 5);
for (var i = 1; i <= 10; i++)
tracker.RecordLatency(i);
// Only the last 5 samples should remain (6, 7, 8, 9, 10)
tracker.SampleCount.ShouldBe(5);
// TotalRequests counts all recorded calls, not just retained ones
tracker.TotalRequests.ShouldBe(10L);
// Minimum of retained samples is 6
tracker.MinLatencyMs.ShouldBe(6.0);
// Maximum of retained samples is 10
tracker.MaxLatencyMs.ShouldBe(10.0);
}
[Fact]
public void Account_RecordServiceLatency_DelegatesToTracker()
{
var account = new Account("test");
account.RecordServiceLatency(50.0);
account.RecordServiceLatency(100.0);
account.LatencyTracker.TotalRequests.ShouldBe(2L);
account.LatencyTracker.AverageLatencyMs.ShouldBe(75.0);
}
}

View File

@@ -0,0 +1,175 @@
// Tests for stream import cycle detection via DFS on Account.
// Go reference: accounts_test.go — TestAccountStreamImportCycles (accounts.go:1627 streamImportFormsCycle).
using NATS.Server.Auth;
namespace NATS.Server.Auth.Tests.Auth;
public class StreamImportCycleTests
{
private static Account CreateAccount(string name) => new(name);
/// <summary>
/// Sets up a public stream export on <paramref name="exporter"/> for <paramref name="subject"/>
/// and then adds a stream import on <paramref name="importer"/> from <paramref name="exporter"/>.
/// </summary>
private static void SetupStreamImport(Account importer, Account exporter, string subject)
{
exporter.AddStreamExport(subject, approved: null); // public export
importer.AddStreamImport(exporter, subject, subject);
}
// 1. No cycle when the proposed source has no imports leading back to this account.
// A imports from B; checking whether B can import from C — no path C→A exists.
[Fact]
public void StreamImportFormsCycle_NoCycle_ReturnsFalse()
{
// Go ref: accounts.go streamImportFormsCycle
var a = CreateAccount("A");
var b = CreateAccount("B");
var c = CreateAccount("C");
SetupStreamImport(a, b, "events.>"); // A imports from B
c.AddStreamExport("other.>", approved: null);
// B importing from C: does C→...→A exist? No.
a.StreamImportFormsCycle(c).ShouldBeFalse();
}
// 2. Direct cycle: A already imports from B; proposing B imports from A = cycle.
[Fact]
public void StreamImportFormsCycle_DirectCycle_ReturnsTrue()
{
// Go ref: accounts.go streamImportFormsCycle
var a = CreateAccount("A");
var b = CreateAccount("B");
SetupStreamImport(a, b, "stream.>"); // A imports from B
// Now check: would A importing from B (again, or B's perspective) form a cycle?
// We ask account B: does proposing A as source form a cycle?
// i.e. b.StreamImportFormsCycle(a) — does a chain from A lead back to B?
// A imports from B, so A→B, meaning following A's imports we reach B. Cycle confirmed.
b.StreamImportFormsCycle(a).ShouldBeTrue();
}
// 3. Indirect cycle: A→B→C; proposing C import from A would create C→A→B→C.
[Fact]
public void StreamImportFormsCycle_IndirectCycle_ReturnsTrue()
{
// Go ref: accounts.go checkStreamImportsForCycles
var a = CreateAccount("A");
var b = CreateAccount("B");
var c = CreateAccount("C");
SetupStreamImport(a, b, "s.>"); // A imports from B
SetupStreamImport(b, c, "t.>"); // B imports from C
// Would C importing from A form a cycle? Path: A imports from B, B imports from C → cycle.
c.StreamImportFormsCycle(a).ShouldBeTrue();
}
// 4. Self-import: A importing from A is always a cycle.
[Fact]
public void StreamImportFormsCycle_SelfImport_ReturnsTrue()
{
// Go ref: accounts.go streamImportFormsCycle — proposedSource == this
var a = CreateAccount("A");
a.StreamImportFormsCycle(a).ShouldBeTrue();
}
// 5. Account with no imports at all — no cycle possible.
[Fact]
public void StreamImportFormsCycle_NoImports_ReturnsFalse()
{
// Go ref: accounts.go streamImportFormsCycle — empty imports.streams
var a = CreateAccount("A");
var b = CreateAccount("B");
// Neither account has any stream imports; proposing B as source for A is safe.
a.StreamImportFormsCycle(b).ShouldBeFalse();
}
// 6. Diamond topology: A→B, A→C, B→D, C→D — no cycle, just shared descendant.
[Fact]
public void StreamImportFormsCycle_DiamondNoCycle_ReturnsFalse()
{
// Go ref: accounts.go checkStreamImportsForCycles — visited set prevents false positives
var a = CreateAccount("A");
var b = CreateAccount("B");
var c = CreateAccount("C");
var d = CreateAccount("D");
SetupStreamImport(a, b, "b.>"); // A imports from B
SetupStreamImport(a, c, "c.>"); // A imports from C
SetupStreamImport(b, d, "d1.>"); // B imports from D
SetupStreamImport(c, d, "d2.>"); // C imports from D
// Proposing D import from A: does A→...→D path exist? Yes (via B and C).
d.StreamImportFormsCycle(a).ShouldBeTrue();
// Proposing E (new account) import from D: D has no imports, so no cycle.
var e = CreateAccount("E");
e.StreamImportFormsCycle(d).ShouldBeFalse();
}
// 7. GetStreamImportSources returns names of source accounts.
[Fact]
public void GetStreamImportSources_ReturnsSourceNames()
{
// Go ref: accounts.go imports.streams acc field
var a = CreateAccount("A");
var b = CreateAccount("B");
var c = CreateAccount("C");
SetupStreamImport(a, b, "x.>");
SetupStreamImport(a, c, "y.>");
var sources = a.GetStreamImportSources();
sources.Count.ShouldBe(2);
sources.ShouldContain("B");
sources.ShouldContain("C");
}
// 8. GetStreamImportSources returns empty list when no imports exist.
[Fact]
public void GetStreamImportSources_Empty_ReturnsEmpty()
{
// Go ref: accounts.go imports.streams — empty slice
var a = CreateAccount("A");
var sources = a.GetStreamImportSources();
sources.ShouldBeEmpty();
}
// 9. HasStreamImportFrom returns true when a matching import exists.
[Fact]
public void HasStreamImportFrom_True()
{
// Go ref: accounts.go imports.streams — acc.Name lookup
var a = CreateAccount("A");
var b = CreateAccount("B");
SetupStreamImport(a, b, "events.>");
a.HasStreamImportFrom("B").ShouldBeTrue();
}
// 10. HasStreamImportFrom returns false when no import from that account exists.
[Fact]
public void HasStreamImportFrom_False()
{
// Go ref: accounts.go imports.streams — acc.Name lookup miss
var a = CreateAccount("A");
var b = CreateAccount("B");
var c = CreateAccount("C");
SetupStreamImport(a, b, "events.>");
a.HasStreamImportFrom("C").ShouldBeFalse();
a.HasStreamImportFrom(c.Name).ShouldBeFalse();
}
}

View File

@@ -0,0 +1,161 @@
using NATS.Server.Auth;
using Shouldly;
namespace NATS.Server.Auth.Tests.Auth;
/// <summary>
/// Tests for SUB permission caching and generation-based invalidation.
/// Reference: Go server/client.go — subPermCache, pubPermCache, perm cache invalidation on account update.
/// </summary>
public sealed class SubPermissionCacheTests
{
// ── SUB API ───────────────────────────────────────────────────────────────
[Fact]
public void SetSub_and_TryGetSub_round_trips()
{
var cache = new PermissionLruCache();
cache.SetSub("foo.bar", true);
cache.TryGetSub("foo.bar", out var result).ShouldBeTrue();
result.ShouldBeTrue();
}
[Fact]
public void TryGetSub_returns_false_for_unknown()
{
var cache = new PermissionLruCache();
cache.TryGetSub("unknown.subject", out var result).ShouldBeFalse();
result.ShouldBeFalse();
}
[Fact]
public void PUB_and_SUB_stored_independently()
{
var cache = new PermissionLruCache();
// Same logical subject, different PUB/SUB outcomes
cache.Set("orders.>", false); // PUB denied
cache.SetSub("orders.>", true); // SUB allowed
cache.TryGet("orders.>", out var pubAllowed).ShouldBeTrue();
pubAllowed.ShouldBeFalse();
cache.TryGetSub("orders.>", out var subAllowed).ShouldBeTrue();
subAllowed.ShouldBeTrue();
}
// ── Invalidation ─────────────────────────────────────────────────────────
[Fact]
public void Invalidate_clears_on_next_access()
{
var cache = new PermissionLruCache();
cache.Set("pub.subject", true);
cache.SetSub("sub.subject", true);
cache.Invalidate();
// Both PUB and SUB lookups should miss after invalidation
cache.TryGet("pub.subject", out _).ShouldBeFalse();
cache.TryGetSub("sub.subject", out _).ShouldBeFalse();
}
[Fact]
public void Generation_increments_on_invalidate()
{
var cache = new PermissionLruCache();
var before = cache.Generation;
cache.Invalidate();
var afterOne = cache.Generation;
cache.Invalidate();
var afterTwo = cache.Generation;
afterOne.ShouldBe(before + 1);
afterTwo.ShouldBe(before + 2);
}
// ── LRU eviction ─────────────────────────────────────────────────────────
[Fact]
public void LRU_eviction_applies_to_SUB_entries()
{
// capacity = 4: fill with 4 SUB entries then add a 5th; the oldest should be evicted
var cache = new PermissionLruCache(capacity: 4);
cache.SetSub("a", true);
cache.SetSub("b", true);
cache.SetSub("c", true);
cache.SetSub("d", true);
// Touch "a" so it becomes MRU; "b" becomes LRU
cache.TryGetSub("a", out _);
// Adding "e" should evict "b" (LRU)
cache.SetSub("e", true);
cache.Count.ShouldBe(4);
cache.TryGetSub("b", out _).ShouldBeFalse("b should have been evicted");
cache.TryGetSub("e", out _).ShouldBeTrue("e was just added");
}
// ── Backward compatibility ────────────────────────────────────────────────
[Fact]
public void Existing_PUB_API_still_works()
{
var cache = new PermissionLruCache();
cache.Set("pub.only", true);
cache.TryGet("pub.only", out var value).ShouldBeTrue();
value.ShouldBeTrue();
// Overwrite with false
cache.Set("pub.only", false);
cache.TryGet("pub.only", out value).ShouldBeTrue();
value.ShouldBeFalse();
}
// ── Account.GenerationId ──────────────────────────────────────────────────
[Fact]
public void Account_GenerationId_starts_at_zero()
{
var account = new Account("test");
account.GenerationId.ShouldBe(0L);
}
[Fact]
public void Account_IncrementGeneration_increments()
{
var account = new Account("test");
account.IncrementGeneration();
account.GenerationId.ShouldBe(1L);
account.IncrementGeneration();
account.GenerationId.ShouldBe(2L);
}
// ── Mixed PUB + SUB count ─────────────────────────────────────────────────
[Fact]
public void Mixed_PUB_SUB_count_includes_both()
{
var cache = new PermissionLruCache();
cache.Set("pub.a", true);
cache.Set("pub.b", false);
cache.SetSub("sub.a", true);
cache.SetSub("sub.b", false);
// All four entries (stored under different internal keys) contribute to Count
cache.Count.ShouldBe(4);
}
}

View File

@@ -0,0 +1,226 @@
// Port of Go server/accounts_test.go — TestSystemAccountDefaultCreation,
// TestSystemAccountSysSubjectRouting, TestNonSystemAccountCannotSubscribeToSys.
// Reference: golang/nats-server/server/accounts_test.go, server.go — initSystemAccount.
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Auth;
using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.Tests.Auth;
/// <summary>
/// Tests for the $SYS system account functionality including:
/// - Default system account creation with IsSystemAccount flag
/// - $SYS.> subject routing to the system account's SubList
/// - Non-system accounts blocked from subscribing to $SYS.> subjects
/// - System account event publishing
/// Reference: Go server/accounts.go — isSystemAccount, isReservedSubject.
/// </summary>
public class SystemAccountTests
{
// ─── Helpers ────────────────────────────────────────────────────────────
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);
}
// ─── Tests ──────────────────────────────────────────────────────────────
/// <summary>
/// Verifies that the server creates a $SYS system account by default with
/// IsSystemAccount set to true.
/// Reference: Go server/server.go — initSystemAccount.
/// </summary>
[Fact]
public void Default_system_account_is_created()
{
var options = new NatsOptions { Port = 0 };
using var server = new NatsServer(options, NullLoggerFactory.Instance);
server.SystemAccount.ShouldNotBeNull();
server.SystemAccount.Name.ShouldBe(Account.SystemAccountName);
server.SystemAccount.IsSystemAccount.ShouldBeTrue();
}
/// <summary>
/// Verifies that the system account constant matches "$SYS".
/// </summary>
[Fact]
public void System_account_name_constant_is_correct()
{
Account.SystemAccountName.ShouldBe("$SYS");
}
/// <summary>
/// Verifies that a non-system account does not have IsSystemAccount set.
/// </summary>
[Fact]
public void Regular_account_is_not_system_account()
{
var account = new Account("test-account");
account.IsSystemAccount.ShouldBeFalse();
}
/// <summary>
/// Verifies that IsSystemAccount can be explicitly set on an account.
/// </summary>
[Fact]
public void IsSystemAccount_can_be_set()
{
var account = new Account("custom-sys") { IsSystemAccount = true };
account.IsSystemAccount.ShouldBeTrue();
}
/// <summary>
/// Verifies that IsSystemSubject correctly identifies $SYS subjects.
/// Reference: Go server/server.go — isReservedSubject.
/// </summary>
[Theory]
[InlineData("$SYS", true)]
[InlineData("$SYS.ACCOUNT.test.CONNECT", true)]
[InlineData("$SYS.SERVER.abc.STATSZ", true)]
[InlineData("$SYS.REQ.SERVER.PING.VARZ", true)]
[InlineData("foo.bar", false)]
[InlineData("$G", false)]
[InlineData("SYS.test", false)]
[InlineData("$JS.API.STREAM.LIST", false)]
[InlineData("$SYS.", true)]
public void IsSystemSubject_identifies_sys_subjects(string subject, bool expected)
{
NatsServer.IsSystemSubject(subject).ShouldBe(expected);
}
/// <summary>
/// Verifies that the system account is listed among server accounts.
/// </summary>
[Fact]
public void System_account_is_in_server_accounts()
{
var options = new NatsOptions { Port = 0 };
using var server = new NatsServer(options, NullLoggerFactory.Instance);
var accounts = server.GetAccounts().ToList();
accounts.ShouldContain(a => a.Name == Account.SystemAccountName && a.IsSystemAccount);
}
/// <summary>
/// Verifies that IsSubscriptionAllowed blocks non-system accounts from $SYS.> subjects.
/// Reference: Go server/accounts.go — isReservedForSys.
/// </summary>
[Fact]
public void Non_system_account_cannot_subscribe_to_sys_subjects()
{
var options = new NatsOptions { Port = 0 };
using var server = new NatsServer(options, NullLoggerFactory.Instance);
var regularAccount = new Account("regular");
server.IsSubscriptionAllowed(regularAccount, "$SYS.SERVER.abc.STATSZ").ShouldBeFalse();
server.IsSubscriptionAllowed(regularAccount, "$SYS.ACCOUNT.test.CONNECT").ShouldBeFalse();
server.IsSubscriptionAllowed(regularAccount, "$SYS.REQ.SERVER.PING.VARZ").ShouldBeFalse();
}
/// <summary>
/// Verifies that the system account IS allowed to subscribe to $SYS.> subjects.
/// </summary>
[Fact]
public void System_account_can_subscribe_to_sys_subjects()
{
var options = new NatsOptions { Port = 0 };
using var server = new NatsServer(options, NullLoggerFactory.Instance);
server.IsSubscriptionAllowed(server.SystemAccount, "$SYS.SERVER.abc.STATSZ").ShouldBeTrue();
server.IsSubscriptionAllowed(server.SystemAccount, "$SYS.ACCOUNT.test.CONNECT").ShouldBeTrue();
}
/// <summary>
/// Verifies that any account can subscribe to non-$SYS subjects.
/// </summary>
[Fact]
public void Any_account_can_subscribe_to_regular_subjects()
{
var options = new NatsOptions { Port = 0 };
using var server = new NatsServer(options, NullLoggerFactory.Instance);
var regularAccount = new Account("regular");
server.IsSubscriptionAllowed(regularAccount, "foo.bar").ShouldBeTrue();
server.IsSubscriptionAllowed(regularAccount, "$JS.API.STREAM.LIST").ShouldBeTrue();
server.IsSubscriptionAllowed(server.SystemAccount, "foo.bar").ShouldBeTrue();
}
/// <summary>
/// Verifies that GetSubListForSubject routes $SYS subjects to the system account's SubList.
/// Reference: Go server/server.go — sublist routing for internal subjects.
/// </summary>
[Fact]
public void GetSubListForSubject_routes_sys_to_system_account()
{
var options = new NatsOptions { Port = 0 };
using var server = new NatsServer(options, NullLoggerFactory.Instance);
var globalAccount = server.GetOrCreateAccount(Account.GlobalAccountName);
// $SYS subjects should route to the system account's SubList
var sysList = server.GetSubListForSubject(globalAccount, "$SYS.SERVER.abc.STATSZ");
sysList.ShouldBeSameAs(server.SystemAccount.SubList);
// Regular subjects should route to the specified account's SubList
var regularList = server.GetSubListForSubject(globalAccount, "foo.bar");
regularList.ShouldBeSameAs(globalAccount.SubList);
}
/// <summary>
/// Verifies that the EventSystem publishes to the system account's SubList
/// and that internal subscriptions for monitoring are registered there.
/// The subscriptions are wired up during StartAsync via InitEventTracking.
/// </summary>
[Fact]
public async Task Event_system_subscribes_in_system_account()
{
var (server, _, cts) = await StartServerAsync(new NatsOptions());
try
{
// The system account's SubList should have subscriptions registered
// by the internal event system (VARZ, HEALTHZ, etc.)
server.EventSystem.ShouldNotBeNull();
server.SystemAccount.SubList.Count.ShouldBeGreaterThan(0u);
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
/// <summary>
/// Verifies that the global account is separate from the system account.
/// </summary>
[Fact]
public void Global_and_system_accounts_are_separate()
{
var options = new NatsOptions { Port = 0 };
using var server = new NatsServer(options, NullLoggerFactory.Instance);
var globalAccount = server.GetOrCreateAccount(Account.GlobalAccountName);
var systemAccount = server.SystemAccount;
globalAccount.ShouldNotBeSameAs(systemAccount);
globalAccount.Name.ShouldBe(Account.GlobalAccountName);
systemAccount.Name.ShouldBe(Account.SystemAccountName);
globalAccount.IsSystemAccount.ShouldBeFalse();
systemAccount.IsSystemAccount.ShouldBeTrue();
globalAccount.SubList.ShouldNotBeSameAs(systemAccount.SubList);
}
}

View File

@@ -0,0 +1,65 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using NATS.Server.Auth;
namespace NATS.Server.Auth.Tests.Auth;
public class TlsMapAuthParityBatch1Tests
{
[Fact]
public void GetTlsAuthDcs_extracts_domain_components_from_subject()
{
using var cert = CreateSelfSignedCert("CN=alice,DC=example,DC=com");
TlsMapAuthenticator.GetTlsAuthDcs(cert.SubjectName).ShouldBe("DC=example,DC=com");
}
[Fact]
public void DnsAltNameLabels_and_matches_follow_rfc6125_shape()
{
var labels = TlsMapAuthenticator.DnsAltNameLabels("*.Example.COM");
labels.ShouldBe(["*", "example", "com"]);
TlsMapAuthenticator.DnsAltNameMatches(labels, [new Uri("nats://node.example.com:6222")]).ShouldBeTrue();
TlsMapAuthenticator.DnsAltNameMatches(labels, [new Uri("nats://a.b.example.com:6222")]).ShouldBeFalse();
}
[Fact]
public void Authenticate_can_match_user_from_email_or_dns_san()
{
using var cert = CreateSelfSignedCertWithSan("CN=ignored", "ops@example.com", "router.example.com");
var auth = new TlsMapAuthenticator([
new User { Username = "ops@example.com", Password = "" },
new User { Username = "router.example.com", Password = "" },
]);
var ctx = new ClientAuthContext
{
Opts = new Protocol.ClientOptions(),
Nonce = [],
ClientCertificate = cert,
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
(result.Identity == "ops@example.com" || result.Identity == "router.example.com").ShouldBeTrue();
}
private static X509Certificate2 CreateSelfSignedCert(string subjectName)
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1));
}
private static X509Certificate2 CreateSelfSignedCertWithSan(string subjectName, string email, string dns)
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var sans = new SubjectAlternativeNameBuilder();
sans.AddEmailAddress(email);
sans.AddDnsName(dns);
req.CertificateExtensions.Add(sans.Build());
return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1));
}
}

View File

@@ -0,0 +1,169 @@
// Tests for wildcard service export matching on Account.
// Go reference: accounts_test.go — getWildcardServiceExport, getServiceExport (accounts.go line 2849).
using NATS.Server.Auth;
using NATS.Server.Imports;
namespace NATS.Server.Auth.Tests.Auth;
public class WildcardExportTests
{
private static Account CreateAccount(string name = "TestAccount") => new(name);
// ──────────────────────────────────────────────────────────────────────────
// GetWildcardServiceExport
// ──────────────────────────────────────────────────────────────────────────
[Fact]
public void GetWildcardServiceExport_ExactMatch_ReturnsExport()
{
// Go ref: accounts.go getWildcardServiceExport — exact key in exports.services map
var acct = CreateAccount();
acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null);
var result = acct.GetWildcardServiceExport("orders.create");
result.ShouldNotBeNull();
result.Subject.ShouldBe("orders.create");
result.ResponseType.ShouldBe(ServiceResponseType.Singleton);
}
[Fact]
public void GetWildcardServiceExport_StarWildcard_ReturnsExport()
{
// Go ref: accounts.go getWildcardServiceExport — isSubsetMatch with '*' wildcard
var acct = CreateAccount();
acct.AddServiceExport("orders.*", ServiceResponseType.Streamed, null);
var result = acct.GetWildcardServiceExport("orders.create");
result.ShouldNotBeNull();
result.Subject.ShouldBe("orders.*");
result.ResponseType.ShouldBe(ServiceResponseType.Streamed);
result.IsWildcard.ShouldBeTrue();
}
[Fact]
public void GetWildcardServiceExport_GtWildcard_ReturnsExport()
{
// Go ref: accounts.go getWildcardServiceExport — isSubsetMatch with '>' wildcard
var acct = CreateAccount();
acct.AddServiceExport("orders.>", ServiceResponseType.Chunked, null);
var result = acct.GetWildcardServiceExport("orders.create.new");
result.ShouldNotBeNull();
result.Subject.ShouldBe("orders.>");
result.ResponseType.ShouldBe(ServiceResponseType.Chunked);
result.IsWildcard.ShouldBeTrue();
}
[Fact]
public void GetWildcardServiceExport_NoMatch_ReturnsNull()
{
// Go ref: accounts.go getWildcardServiceExport — returns nil when no pattern matches
var acct = CreateAccount();
acct.AddServiceExport("payments.*", ServiceResponseType.Singleton, null);
var result = acct.GetWildcardServiceExport("orders.create");
result.ShouldBeNull();
}
// ──────────────────────────────────────────────────────────────────────────
// GetAllServiceExports
// ──────────────────────────────────────────────────────────────────────────
[Fact]
public void GetAllServiceExports_ReturnsAll()
{
// Go ref: accounts.go — exports.services map contains all registered exports
var acct = CreateAccount();
acct.AddServiceExport("svc.a", ServiceResponseType.Singleton, null);
acct.AddServiceExport("svc.b.*", ServiceResponseType.Streamed, null);
acct.AddServiceExport("svc.>", ServiceResponseType.Chunked, null);
var all = acct.GetAllServiceExports();
all.Count.ShouldBe(3);
all.Select(e => e.Subject).ShouldContain("svc.a");
all.Select(e => e.Subject).ShouldContain("svc.b.*");
all.Select(e => e.Subject).ShouldContain("svc.>");
}
// ──────────────────────────────────────────────────────────────────────────
// GetExactServiceExport
// ──────────────────────────────────────────────────────────────────────────
[Fact]
public void GetExactServiceExport_Found()
{
// Go ref: accounts.go getServiceExport — direct map lookup, no wildcard scan
var acct = CreateAccount();
acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null);
var result = acct.GetExactServiceExport("orders.create");
result.ShouldNotBeNull();
result.Subject.ShouldBe("orders.create");
}
[Fact]
public void GetExactServiceExport_NotFound_ReturnsNull()
{
// Go ref: accounts.go getServiceExport — map lookup misses wildcard patterns
var acct = CreateAccount();
acct.AddServiceExport("orders.*", ServiceResponseType.Singleton, null);
// "orders.create" is not an exact key in the map — only "orders.*" is
var result = acct.GetExactServiceExport("orders.create");
result.ShouldBeNull();
}
// ──────────────────────────────────────────────────────────────────────────
// HasServiceExport
// ──────────────────────────────────────────────────────────────────────────
[Fact]
public void HasServiceExport_ExactMatch_ReturnsTrue()
{
// Go ref: accounts.go — exact subject registered as an export
var acct = CreateAccount();
acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null);
acct.HasServiceExport("orders.create").ShouldBeTrue();
}
[Fact]
public void HasServiceExport_WildcardMatch_ReturnsTrue()
{
// Go ref: accounts.go — wildcard pattern covers the queried literal subject
var acct = CreateAccount();
acct.AddServiceExport("orders.>", ServiceResponseType.Singleton, null);
acct.HasServiceExport("orders.create.urgent").ShouldBeTrue();
}
// ──────────────────────────────────────────────────────────────────────────
// IsWildcard flag
// ──────────────────────────────────────────────────────────────────────────
[Theory]
[InlineData("orders.*", true)]
[InlineData("orders.>", true)]
[InlineData("orders.*.create", true)]
[InlineData("orders.create", false)]
[InlineData("svc", false)]
public void IsWildcard_DetectsWildcardSubjects(string subject, bool expectedWildcard)
{
// Go ref: accounts.go — wildcard subjects contain '*' or '>'
var acct = CreateAccount();
acct.AddServiceExport(subject, ServiceResponseType.Singleton, null);
var result = acct.GetExactServiceExport(subject);
result.ShouldNotBeNull();
result.IsWildcard.ShouldBe(expectedWildcard);
}
}