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:
173
tests/NATS.Server.Auth.Tests/Auth/AccountClaimReloadTests.cs
Normal file
173
tests/NATS.Server.Auth.Tests/Auth/AccountClaimReloadTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
135
tests/NATS.Server.Auth.Tests/Auth/AccountExpirationTests.cs
Normal file
135
tests/NATS.Server.Auth.Tests/Auth/AccountExpirationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
481
tests/NATS.Server.Auth.Tests/Auth/AccountGoParityTests.cs
Normal file
481
tests/NATS.Server.Auth.Tests/Auth/AccountGoParityTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
211
tests/NATS.Server.Auth.Tests/Auth/AccountImportExportTests.cs
Normal file
211
tests/NATS.Server.Auth.Tests/Auth/AccountImportExportTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
169
tests/NATS.Server.Auth.Tests/Auth/AccountLimitsTests.cs
Normal file
169
tests/NATS.Server.Auth.Tests/Auth/AccountLimitsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
1006
tests/NATS.Server.Auth.Tests/Auth/AccountRoutingTests.cs
Normal file
1006
tests/NATS.Server.Auth.Tests/Auth/AccountRoutingTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
200
tests/NATS.Server.Auth.Tests/Auth/ActivationExpirationTests.cs
Normal file
200
tests/NATS.Server.Auth.Tests/Auth/ActivationExpirationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
1533
tests/NATS.Server.Auth.Tests/Auth/AuthCalloutGoParityTests.cs
Normal file
1533
tests/NATS.Server.Auth.Tests/Auth/AuthCalloutGoParityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
170
tests/NATS.Server.Auth.Tests/Auth/ImportShadowingTests.cs
Normal file
170
tests/NATS.Server.Auth.Tests/Auth/ImportShadowingTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
1770
tests/NATS.Server.Auth.Tests/Auth/JwtGoParityTests.cs
Normal file
1770
tests/NATS.Server.Auth.Tests/Auth/JwtGoParityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
165
tests/NATS.Server.Auth.Tests/Auth/NKeyRevocationTests.cs
Normal file
165
tests/NATS.Server.Auth.Tests/Auth/NKeyRevocationTests.cs
Normal 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("*");
|
||||
}
|
||||
}
|
||||
28
tests/NATS.Server.Auth.Tests/Auth/ProxyAuthTests.cs
Normal file
28
tests/NATS.Server.Auth.Tests/Auth/ProxyAuthTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
137
tests/NATS.Server.Auth.Tests/Auth/ResponseThresholdTests.cs
Normal file
137
tests/NATS.Server.Auth.Tests/Auth/ResponseThresholdTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
174
tests/NATS.Server.Auth.Tests/Auth/ReverseResponseMapTests.cs
Normal file
174
tests/NATS.Server.Auth.Tests/Auth/ReverseResponseMapTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
168
tests/NATS.Server.Auth.Tests/Auth/ServiceLatencyTrackerTests.cs
Normal file
168
tests/NATS.Server.Auth.Tests/Auth/ServiceLatencyTrackerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
175
tests/NATS.Server.Auth.Tests/Auth/StreamImportCycleTests.cs
Normal file
175
tests/NATS.Server.Auth.Tests/Auth/StreamImportCycleTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
161
tests/NATS.Server.Auth.Tests/Auth/SubPermissionCacheTests.cs
Normal file
161
tests/NATS.Server.Auth.Tests/Auth/SubPermissionCacheTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
226
tests/NATS.Server.Auth.Tests/Auth/SystemAccountTests.cs
Normal file
226
tests/NATS.Server.Auth.Tests/Auth/SystemAccountTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
169
tests/NATS.Server.Auth.Tests/Auth/WildcardExportTests.cs
Normal file
169
tests/NATS.Server.Auth.Tests/Auth/WildcardExportTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user