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:
@@ -35,18 +35,53 @@ public sealed class Account : IDisposable
|
||||
public Dictionary<string, object>? SigningKeys { get; set; }
|
||||
private readonly ConcurrentDictionary<string, long> _revokedUsers = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Special key used to revoke all users regardless of their NKey.</summary>
|
||||
/// <remarks>Go reference: jwt.All constant used in accounts.go isRevoked (~line 2934).</remarks>
|
||||
private const string GlobalRevocationKey = "*";
|
||||
|
||||
public void RevokeUser(string userNkey, long issuedAt) => _revokedUsers[userNkey] = issuedAt;
|
||||
|
||||
/// <summary>
|
||||
/// Revokes all users with a JWT issued at or before <paramref name="issuedBefore"/>.
|
||||
/// Uses the special key "*" (global revocation) to indicate all users are revoked
|
||||
/// up to the given timestamp.
|
||||
/// Go reference: accounts.go — Revocations[jwt.All] assignment (~line 3887).
|
||||
/// </summary>
|
||||
public void RevokeAllUsers(long issuedBefore) => _revokedUsers[GlobalRevocationKey] = issuedBefore;
|
||||
|
||||
public bool IsUserRevoked(string userNkey, long issuedAt)
|
||||
{
|
||||
if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
|
||||
return issuedAt <= revokedAt;
|
||||
// Check "*" wildcard for all-user revocation
|
||||
if (_revokedUsers.TryGetValue("*", out revokedAt))
|
||||
if (_revokedUsers.TryGetValue(GlobalRevocationKey, out revokedAt))
|
||||
return issuedAt <= revokedAt;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Returns the number of revoked user NKey entries (including any global revocation entry).</summary>
|
||||
public int RevokedUserCount => _revokedUsers.Count;
|
||||
|
||||
/// <summary>Returns true when a global ("*") revocation entry is present.</summary>
|
||||
/// <remarks>Go reference: accounts.go — Revocations[jwt.All] check (~line 3887).</remarks>
|
||||
public bool IsGlobalRevocation() => _revokedUsers.ContainsKey(GlobalRevocationKey);
|
||||
|
||||
/// <summary>Returns a snapshot list of all revoked NKeys currently in the revocation dictionary.</summary>
|
||||
public IReadOnlyList<string> GetRevokedUsers() => [.. _revokedUsers.Keys];
|
||||
|
||||
/// <summary>
|
||||
/// Removes the revocation entry for <paramref name="userNkey"/>.
|
||||
/// Returns <see langword="true"/> if the entry was found and removed.
|
||||
/// </summary>
|
||||
public bool UnrevokeUser(string userNkey) => _revokedUsers.TryRemove(userNkey, out _);
|
||||
|
||||
/// <summary>Removes all revocation entries, including any global ("*") revocation.</summary>
|
||||
public void ClearAllRevocations() => _revokedUsers.Clear();
|
||||
|
||||
/// <summary>Returns a summary snapshot of the current revocation state for this account.</summary>
|
||||
public RevocationInfo GetRevocationInfo() =>
|
||||
new(RevokedUserCount, IsGlobalRevocation(), GetRevokedUsers());
|
||||
|
||||
private readonly ConcurrentDictionary<ulong, byte> _clients = new();
|
||||
private int _subscriptionCount;
|
||||
private int _jetStreamStreamCount;
|
||||
@@ -458,6 +493,174 @@ public sealed class Account : IDisposable
|
||||
return new ServiceResponseThresholdResult(Found: true, IsOverdue: overdue, Threshold: threshold, Elapsed: elapsed);
|
||||
}
|
||||
|
||||
// Account expiration / TTL
|
||||
// Go reference: server/accounts.go — account expiry fields and SetExpirationTimer.
|
||||
//
|
||||
// Implementation note: Volatile.Read/Write require reference types; DateTime? is a value type.
|
||||
// We store expiry as UTC ticks in a long field. long.MinValue is the "no expiration" sentinel
|
||||
// (default value for an uninitialized long field).
|
||||
private const long NoExpiration = long.MinValue;
|
||||
private long _expiresAtTicks = NoExpiration;
|
||||
|
||||
// Converts the raw ticks field to a nullable DateTime for callers.
|
||||
private DateTime? ReadExpiry()
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _expiresAtTicks);
|
||||
return ticks == NoExpiration ? null : new DateTime(ticks, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The UTC time at which this account expires, or <see langword="null"/> when no expiration is set.
|
||||
/// Go reference: accounts.go — account.expiry field.
|
||||
/// </summary>
|
||||
public DateTime? ExpiresAt => ReadExpiry();
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when an expiration has been set and
|
||||
/// <see cref="DateTime.UtcNow"/> is at or past <see cref="ExpiresAt"/>.
|
||||
/// Go reference: accounts.go — isExpired() check.
|
||||
/// </summary>
|
||||
public bool IsExpired
|
||||
{
|
||||
get
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _expiresAtTicks);
|
||||
return ticks != NoExpiration && DateTime.UtcNow.Ticks >= ticks;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the time remaining until expiry, <see cref="TimeSpan.Zero"/> if already expired,
|
||||
/// or <see langword="null"/> when no expiration is set.
|
||||
/// Go reference: accounts.go — expiry-based TTL computation.
|
||||
/// </summary>
|
||||
public TimeSpan? TimeToExpiry
|
||||
{
|
||||
get
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _expiresAtTicks);
|
||||
if (ticks == NoExpiration)
|
||||
return null;
|
||||
var remaining = ticks - DateTime.UtcNow.Ticks;
|
||||
return remaining > 0 ? TimeSpan.FromTicks(remaining) : TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the UTC expiration time for this account.
|
||||
/// Go reference: accounts.go — SetExpirationTimer / account.expiry assignment.
|
||||
/// </summary>
|
||||
public void SetExpiration(DateTime expiresAtUtc) =>
|
||||
Interlocked.Exchange(ref _expiresAtTicks, DateTime.SpecifyKind(expiresAtUtc, DateTimeKind.Utc).Ticks);
|
||||
|
||||
/// <summary>Removes any previously set expiration, making the account permanent.</summary>
|
||||
public void ClearExpiration() => Interlocked.Exchange(ref _expiresAtTicks, NoExpiration);
|
||||
|
||||
/// <summary>
|
||||
/// Convenience method: sets the expiration to <c>DateTime.UtcNow + <paramref name="ttl"/></c>.
|
||||
/// Go reference: accounts.go — SetExpirationTimer with duration argument.
|
||||
/// </summary>
|
||||
public void SetExpirationFromTtl(TimeSpan ttl) => SetExpiration(DateTime.UtcNow + ttl);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of the current expiration state for this account.
|
||||
/// Go reference: accounts.go — account expiry inspection.
|
||||
/// </summary>
|
||||
public AccountExpirationInfo GetExpirationInfo()
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _expiresAtTicks);
|
||||
bool hasExpiration = ticks != NoExpiration;
|
||||
DateTime? exp = hasExpiration ? new DateTime(ticks, DateTimeKind.Utc) : null;
|
||||
bool isExpired = hasExpiration && DateTime.UtcNow.Ticks >= ticks;
|
||||
TimeSpan? tte = hasExpiration
|
||||
? (isExpired ? TimeSpan.Zero : TimeSpan.FromTicks(ticks - DateTime.UtcNow.Ticks))
|
||||
: null;
|
||||
return new AccountExpirationInfo(Name, hasExpiration, exp, isExpired, tte);
|
||||
}
|
||||
|
||||
// JWT Activation claim expiration tracking
|
||||
// Go reference: server/accounts.go — checkActivation (~line 2943), activationExpired (~line 2920).
|
||||
private readonly ConcurrentDictionary<string, ActivationClaim> _activations =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a JWT activation claim for the given subject.
|
||||
/// Go reference: accounts.go — checkActivation registers expiry timers for activation tokens.
|
||||
/// </summary>
|
||||
public void RegisterActivation(string subject, ActivationClaim claim) =>
|
||||
_activations[subject] = claim;
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the activation for <paramref name="subject"/> has expired.
|
||||
/// Returns a result indicating whether the claim was found and whether it is expired.
|
||||
/// Go reference: accounts.go — checkActivation (~line 2943): act.Expires <= tn ⇒ expired.
|
||||
/// </summary>
|
||||
public ActivationCheckResult CheckActivationExpiry(string subject)
|
||||
{
|
||||
if (!_activations.TryGetValue(subject, out var claim))
|
||||
return new ActivationCheckResult(Found: false, IsExpired: false, ExpiresAt: null, TimeToExpiry: null);
|
||||
|
||||
var expired = claim.IsExpired;
|
||||
var tte = expired ? TimeSpan.Zero : claim.TimeToExpiry;
|
||||
return new ActivationCheckResult(Found: true, IsExpired: expired, ExpiresAt: claim.ExpiresAt, TimeToExpiry: tte);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the activation for <paramref name="subject"/> exists
|
||||
/// and has passed its expiry time.
|
||||
/// Go reference: accounts.go — act.Expires <= tn check inside checkActivation.
|
||||
/// </summary>
|
||||
public bool IsActivationExpired(string subject) =>
|
||||
_activations.TryGetValue(subject, out var claim) && claim.IsExpired;
|
||||
|
||||
/// <summary>
|
||||
/// Returns all subjects whose activation claim has expired.
|
||||
/// Go reference: accounts.go — activationExpired fires per-subject when expiry timer triggers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> GetExpiredActivations()
|
||||
{
|
||||
var expired = new List<string>();
|
||||
foreach (var (subject, claim) in _activations)
|
||||
{
|
||||
if (claim.IsExpired)
|
||||
expired.Add(subject);
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all expired activation claims and returns the count removed.
|
||||
/// Go reference: accounts.go — activationExpired cleans up service/stream imports.
|
||||
/// </summary>
|
||||
public int RemoveExpiredActivations()
|
||||
{
|
||||
int count = 0;
|
||||
foreach (var (subject, claim) in _activations)
|
||||
{
|
||||
if (claim.IsExpired && _activations.TryRemove(subject, out _))
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of activation claims that have not yet expired.
|
||||
/// Go reference: accounts.go — active activation tracking.
|
||||
/// </summary>
|
||||
public int ActiveActivationCount
|
||||
{
|
||||
get
|
||||
{
|
||||
int count = 0;
|
||||
foreach (var (_, claim) in _activations)
|
||||
{
|
||||
if (!claim.IsExpired)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => SubList.Dispose();
|
||||
}
|
||||
|
||||
@@ -473,3 +676,75 @@ public sealed record ServiceResponseThresholdResult(
|
||||
bool IsOverdue,
|
||||
TimeSpan? Threshold,
|
||||
TimeSpan Elapsed);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of an account's expiration state returned by <see cref="Account.GetExpirationInfo"/>.
|
||||
/// Go reference: accounts.go — account expiry fields.
|
||||
/// </summary>
|
||||
/// <param name="AccountName">The name of the account.</param>
|
||||
/// <param name="HasExpiration">Whether an expiration time has been set.</param>
|
||||
/// <param name="ExpiresAt">The UTC expiry time, or <see langword="null"/> if none is set.</param>
|
||||
/// <param name="IsExpired">Whether the account is already past its expiry time.</param>
|
||||
/// <param name="TimeToExpiry">
|
||||
/// Time remaining until expiry; <see cref="TimeSpan.Zero"/> when already expired;
|
||||
/// <see langword="null"/> when no expiry is configured.
|
||||
/// </param>
|
||||
public sealed record AccountExpirationInfo(
|
||||
string AccountName,
|
||||
bool HasExpiration,
|
||||
DateTime? ExpiresAt,
|
||||
bool IsExpired,
|
||||
TimeSpan? TimeToExpiry);
|
||||
|
||||
/// <summary>
|
||||
/// Summary snapshot of an account's revocation state returned by <see cref="Account.GetRevocationInfo"/>.
|
||||
/// Go reference: accounts.go — usersRevoked map and Revocations[jwt.All] global key (~line 2934).
|
||||
/// </summary>
|
||||
/// <param name="RevokedCount">Total number of revocation entries, including any global entry.</param>
|
||||
/// <param name="HasGlobalRevocation">Whether the special "*" global revocation key is present.</param>
|
||||
/// <param name="RevokedNKeys">Snapshot list of all revoked NKey strings.</param>
|
||||
public sealed record RevocationInfo(
|
||||
int RevokedCount,
|
||||
bool HasGlobalRevocation,
|
||||
IReadOnlyList<string> RevokedNKeys);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a JWT activation claim for a service or stream import.
|
||||
/// Go reference: server/accounts.go — jwt.ActivationClaims decoded inside checkActivation (~line 2955).
|
||||
/// </summary>
|
||||
public sealed class ActivationClaim
|
||||
{
|
||||
public required string Subject { get; init; }
|
||||
public required DateTime IssuedAt { get; init; }
|
||||
public required DateTime ExpiresAt { get; init; }
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when <see cref="DateTime.UtcNow"/> is at or past <see cref="ExpiresAt"/>.
|
||||
/// Go reference: accounts.go — act.Expires <= tn check.
|
||||
/// </summary>
|
||||
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
|
||||
|
||||
/// <summary>
|
||||
/// Time remaining until expiry, or <see cref="TimeSpan.Zero"/> if already expired.
|
||||
/// Go reference: accounts.go — expiresAt duration computed from act.Expires - time.Now().Unix().
|
||||
/// </summary>
|
||||
public TimeSpan TimeToExpiry => IsExpired ? TimeSpan.Zero : ExpiresAt - DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="Account.CheckActivationExpiry"/> for a given subject.
|
||||
/// Go reference: server/accounts.go — checkActivation return value (~line 2943).
|
||||
/// </summary>
|
||||
/// <param name="Found">Whether an activation claim was registered for the subject.</param>
|
||||
/// <param name="IsExpired">Whether the claim has passed its expiry time.</param>
|
||||
/// <param name="ExpiresAt">The UTC expiry time of the claim, or <see langword="null"/> when not found.</param>
|
||||
/// <param name="TimeToExpiry">
|
||||
/// Time remaining until expiry; <see cref="TimeSpan.Zero"/> when already expired;
|
||||
/// <see langword="null"/> when the subject has no registered claim.
|
||||
/// </param>
|
||||
public sealed record ActivationCheckResult(
|
||||
bool Found,
|
||||
bool IsExpired,
|
||||
DateTime? ExpiresAt,
|
||||
TimeSpan? TimeToExpiry);
|
||||
|
||||
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