feat: add account expiration with TTL-based cleanup (Gap 9.5)
Add ExpiresAt, IsExpired, TimeToExpiry, SetExpiration, ClearExpiration, SetExpirationFromTtl, and GetExpirationInfo to Account. Expiry is stored as UTC ticks in a long field (long.MinValue sentinel) for lock-free reads via Interlocked. Add AccountExpirationInfo record. 10 new tests cover all behaviours.
This commit is contained in:
135
tests/NATS.Server.Tests/Auth/AccountExpirationTests.cs
Normal file
135
tests/NATS.Server.Tests/Auth/AccountExpirationTests.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using NATS.Server.Auth;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.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);
|
||||
}
|
||||
}
|
||||
165
tests/NATS.Server.Tests/Auth/NKeyRevocationTests.cs
Normal file
165
tests/NATS.Server.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.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("*");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user