771 lines
28 KiB
C#
771 lines
28 KiB
C#
// Copyright 2012-2025 The NATS Authors
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
// you may not use this file except in compliance with the License.
|
||
// You may obtain a copy of the License at
|
||
//
|
||
// http://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing, software
|
||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
// See the License for the specific language governing permissions and
|
||
// limitations under the License.
|
||
//
|
||
// Mirrors server/dirstore_test.go tests 285–296 in the NATS server Go source.
|
||
// The Go tests use nkeys.CreateAccount() + jwt.NewAccountClaims() to generate
|
||
// real signed JWTs. Here we craft minimal fake JWT strings directly using
|
||
// Base64URL-encoded JSON payloads, since DirJwtStore only parses the "exp",
|
||
// "iat" and "jti" numeric/string claims from the payload.
|
||
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using Shouldly;
|
||
|
||
namespace ZB.MOM.NatsNet.Server.Tests.Accounts;
|
||
|
||
/// <summary>
|
||
/// Unit tests for <see cref="DirJwtStore"/> expiration, limits, LRU eviction,
|
||
/// reload, TTL and notification behaviour.
|
||
/// Mirrors server/dirstore_test.go tests 285–296.
|
||
/// </summary>
|
||
[Collection("DirectoryStoreTests")]
|
||
public sealed class DirectoryStoreTests : IDisposable
|
||
{
|
||
// -------------------------------------------------------------------------
|
||
// Counter for unique public-key names
|
||
// -------------------------------------------------------------------------
|
||
|
||
private static int _counter;
|
||
|
||
private static string NextKey() =>
|
||
$"ACCT{Interlocked.Increment(ref _counter):D8}";
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Temp directory management
|
||
// -------------------------------------------------------------------------
|
||
|
||
private readonly List<string> _tempDirs = [];
|
||
|
||
private string MakeTempDir()
|
||
{
|
||
var dir = Path.Combine(Path.GetTempPath(), "dirstore_" + Path.GetRandomFileName());
|
||
Directory.CreateDirectory(dir);
|
||
_tempDirs.Add(dir);
|
||
return dir;
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
foreach (var dir in _tempDirs)
|
||
try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ }
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Helpers — fake JWT construction
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Builds a minimal fake JWT string: header.payload.signature
|
||
/// where the payload contains "exp", "iat" and "jti" claims.
|
||
/// </summary>
|
||
private static string MakeFakeJwt(
|
||
long expUnixSeconds,
|
||
long iatUnixSeconds = 0,
|
||
string? jti = null)
|
||
{
|
||
if (iatUnixSeconds == 0)
|
||
iatUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||
jti ??= Guid.NewGuid().ToString("N");
|
||
|
||
var payloadObj = expUnixSeconds > 0
|
||
? $"{{\"jti\":\"{jti}\",\"iat\":{iatUnixSeconds},\"exp\":{expUnixSeconds}}}"
|
||
: $"{{\"jti\":\"{jti}\",\"iat\":{iatUnixSeconds}}}";
|
||
|
||
var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"ed25519-nkey\",\"typ\":\"JWT\"}"));
|
||
var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadObj));
|
||
var sigB64 = Base64UrlEncode(new byte[64]); // dummy 64-byte signature
|
||
return $"{headerB64}.{payloadB64}.{sigB64}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Rounds a <see cref="DateTimeOffset"/> to the nearest whole second,
|
||
/// mirroring Go's <c>time.Now().Round(time.Second)</c>.
|
||
/// </summary>
|
||
private static DateTimeOffset RoundToSecond(DateTimeOffset dt) =>
|
||
dt.Millisecond >= 500
|
||
? new DateTimeOffset(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Offset).AddSeconds(1)
|
||
: new DateTimeOffset(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Offset);
|
||
|
||
private static string Base64UrlEncode(byte[] data)
|
||
{
|
||
return Convert.ToBase64String(data)
|
||
.TrimEnd('=')
|
||
.Replace('+', '-')
|
||
.Replace('/', '_');
|
||
}
|
||
|
||
/// <summary>
|
||
/// Creates and saves a test account JWT in the store.
|
||
/// <paramref name="expSec"/> == 0 means no expiration.
|
||
/// Returns the saved JWT string.
|
||
/// </summary>
|
||
private static string CreateTestAccount(DirJwtStore store, string pubKey, int expSec)
|
||
{
|
||
long exp = expSec > 0
|
||
// Round to the nearest second first (mirrors Go's time.Now().Round(time.Second).Add(...).Unix()),
|
||
// ensuring the expiry is at a whole-second boundary and avoiding sub-second truncation races.
|
||
? RoundToSecond(DateTimeOffset.UtcNow).AddSeconds(expSec).ToUnixTimeSeconds()
|
||
: 0;
|
||
var theJwt = MakeFakeJwt(exp);
|
||
store.SaveAcc(pubKey, theJwt);
|
||
return theJwt;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Counts non-deleted .jwt files in <paramref name="dir"/> recursively.
|
||
/// </summary>
|
||
private static int CountJwtFiles(string dir) =>
|
||
Directory.GetFiles(dir, "*.jwt", SearchOption.AllDirectories)
|
||
.Count(f => !f.EndsWith(".jwt.deleted", StringComparison.Ordinal));
|
||
|
||
// -------------------------------------------------------------------------
|
||
// T:285 — TestExpiration
|
||
// -------------------------------------------------------------------------
|
||
|
||
[Fact] // T:285
|
||
public async Task Expiration_ExpiredAccountIsRemovedByBackground()
|
||
{
|
||
var dir = MakeTempDir();
|
||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||
dir, shard: false, create: false,
|
||
deleteType: JwtDeleteType.NoDelete,
|
||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||
limit: 10,
|
||
evictOnLimit: true,
|
||
ttl: TimeSpan.Zero,
|
||
changeNotification: null);
|
||
|
||
var hBegin = store.Hash();
|
||
|
||
// Add one account that should NOT expire (100-second TTL).
|
||
var keyNoExp = NextKey();
|
||
CreateTestAccount(store, keyNoExp, 100);
|
||
var hNoExp = store.Hash();
|
||
hNoExp.ShouldNotBe(hBegin);
|
||
|
||
// Add one account that should expire in ~1 second.
|
||
var keyExp = NextKey();
|
||
CreateTestAccount(store, keyExp, 1);
|
||
CountJwtFiles(dir).ShouldBe(2);
|
||
|
||
// Wait up to 4 s for the expired file to vanish.
|
||
var deadline = DateTime.UtcNow.AddSeconds(4);
|
||
while (DateTime.UtcNow < deadline)
|
||
{
|
||
await Task.Delay(100);
|
||
if (CountJwtFiles(dir) == 1)
|
||
break;
|
||
}
|
||
|
||
CountJwtFiles(dir).ShouldBe(1, "expired account should be removed");
|
||
|
||
// Hash after expiry should equal hash after adding only the non-expiring key.
|
||
var lh = store.Hash();
|
||
lh.ShouldBe(hNoExp);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// T:286 — TestLimit
|
||
// -------------------------------------------------------------------------
|
||
|
||
[Fact] // T:286
|
||
public void Limit_LruEvictsOldestEntries()
|
||
{
|
||
var dir = MakeTempDir();
|
||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||
dir, shard: false, create: false,
|
||
deleteType: JwtDeleteType.NoDelete,
|
||
expireCheck: TimeSpan.FromMilliseconds(100),
|
||
limit: 5,
|
||
evictOnLimit: true,
|
||
ttl: TimeSpan.Zero,
|
||
changeNotification: null);
|
||
|
||
var h = store.Hash();
|
||
|
||
// Update the first account 10 times — should remain as 1 entry.
|
||
var firstKey = NextKey();
|
||
for (var i = 0; i < 10; i++)
|
||
{
|
||
CreateTestAccount(store, firstKey, 50);
|
||
CountJwtFiles(dir).ShouldBe(1);
|
||
}
|
||
|
||
// Add 10 more new accounts — limit is 5, LRU eviction kicks in.
|
||
for (var i = 0; i < 10; i++)
|
||
{
|
||
var k = NextKey();
|
||
CreateTestAccount(store, k, i + 1); // short but non-zero expiry
|
||
var nh = store.Hash();
|
||
nh.ShouldNotBe(h);
|
||
h = nh;
|
||
}
|
||
|
||
// After all adds, only 5 files should remain.
|
||
CountJwtFiles(dir).ShouldBe(5);
|
||
|
||
// The first account should have been evicted.
|
||
File.Exists(Path.Combine(dir, firstKey + ".jwt")).ShouldBeFalse();
|
||
|
||
// Updating the first account again should succeed (limit allows eviction).
|
||
for (var i = 0; i < 10; i++)
|
||
{
|
||
CreateTestAccount(store, firstKey, 50);
|
||
CountJwtFiles(dir).ShouldBe(5);
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// T:287 — TestLimitNoEvict
|
||
// -------------------------------------------------------------------------
|
||
|
||
[Fact] // T:287
|
||
public async Task LimitNoEvict_StoreFullThrowsOnNewKey()
|
||
{
|
||
var dir = MakeTempDir();
|
||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||
dir, shard: false, create: false,
|
||
deleteType: JwtDeleteType.NoDelete,
|
||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||
limit: 2,
|
||
evictOnLimit: false,
|
||
ttl: TimeSpan.Zero,
|
||
changeNotification: null);
|
||
|
||
var key1 = NextKey();
|
||
var key2 = NextKey();
|
||
var key3 = NextKey();
|
||
|
||
CreateTestAccount(store, key1, 100);
|
||
CountJwtFiles(dir).ShouldBe(1);
|
||
|
||
// key2 expires in 1 second
|
||
CreateTestAccount(store, key2, 1);
|
||
CountJwtFiles(dir).ShouldBe(2);
|
||
|
||
var hashBefore = store.Hash();
|
||
|
||
// Attempting to add key3 should throw (limit=2, no evict).
|
||
var exp3 = DateTimeOffset.UtcNow.AddSeconds(100).ToUnixTimeSeconds();
|
||
var jwt3 = MakeFakeJwt(exp3);
|
||
Should.Throw<InvalidOperationException>(() => store.SaveAcc(key3, jwt3));
|
||
CountJwtFiles(dir).ShouldBe(2);
|
||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
|
||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeFalse();
|
||
|
||
// Hash should not change after the failed add.
|
||
store.Hash().ShouldBe(hashBefore);
|
||
|
||
// Wait for key2 to expire.
|
||
await Task.Delay(2200);
|
||
|
||
// Now adding key3 should succeed.
|
||
store.SaveAcc(key3, jwt3);
|
||
CountJwtFiles(dir).ShouldBe(2);
|
||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
|
||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// T:288 — TestLruLoad
|
||
// -------------------------------------------------------------------------
|
||
|
||
[Fact] // T:288
|
||
public void LruLoad_LoadReordersLru()
|
||
{
|
||
var dir = MakeTempDir();
|
||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||
dir, shard: false, create: false,
|
||
deleteType: JwtDeleteType.NoDelete,
|
||
expireCheck: TimeSpan.FromMilliseconds(100),
|
||
limit: 2,
|
||
evictOnLimit: true,
|
||
ttl: TimeSpan.Zero,
|
||
changeNotification: null);
|
||
|
||
var key1 = NextKey();
|
||
var key2 = NextKey();
|
||
var key3 = NextKey();
|
||
|
||
CreateTestAccount(store, key1, 10);
|
||
CountJwtFiles(dir).ShouldBe(1);
|
||
CreateTestAccount(store, key2, 10);
|
||
CountJwtFiles(dir).ShouldBe(2);
|
||
|
||
// Access key1 — makes it the most-recently-used.
|
||
store.LoadAcc(key1);
|
||
|
||
// Adding key3 should evict key2 (oldest), not key1.
|
||
CreateTestAccount(store, key3, 10);
|
||
CountJwtFiles(dir).ShouldBe(2);
|
||
|
||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
|
||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// T:289 — TestLruVolume
|
||
// -------------------------------------------------------------------------
|
||
|
||
[Fact] // T:289
|
||
public void LruVolume_ContinuousReplacementsAlwaysEvictsOldest()
|
||
{
|
||
var dir = MakeTempDir();
|
||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||
dir, shard: false, create: false,
|
||
deleteType: JwtDeleteType.NoDelete,
|
||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||
limit: 2,
|
||
evictOnLimit: true,
|
||
ttl: TimeSpan.Zero,
|
||
changeNotification: null);
|
||
|
||
const int ReplaceCnt = 200; // must be > 2 due to the invariant
|
||
var keys = new string[ReplaceCnt];
|
||
|
||
keys[0] = NextKey();
|
||
CreateTestAccount(store, keys[0], 10000);
|
||
CountJwtFiles(dir).ShouldBe(1);
|
||
|
||
keys[1] = NextKey();
|
||
CreateTestAccount(store, keys[1], 10000);
|
||
CountJwtFiles(dir).ShouldBe(2);
|
||
|
||
for (var i = 2; i < ReplaceCnt; i++)
|
||
{
|
||
keys[i] = NextKey();
|
||
CreateTestAccount(store, keys[i], 10000);
|
||
CountJwtFiles(dir).ShouldBe(2);
|
||
|
||
// key two positions back should have been evicted.
|
||
File.Exists(Path.Combine(dir, keys[i - 2] + ".jwt")).ShouldBeFalse(
|
||
$"key[{i - 2}] should be evicted after adding key[{i}]");
|
||
// key one position back should still be present.
|
||
File.Exists(Path.Combine(dir, keys[i - 1] + ".jwt")).ShouldBeTrue();
|
||
// current key should be present.
|
||
File.Exists(Path.Combine(dir, keys[i] + ".jwt")).ShouldBeTrue();
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// T:290 — TestLru
|
||
// -------------------------------------------------------------------------
|
||
|
||
[Fact] // T:290
|
||
public async Task Lru_EvictsAndExpires()
|
||
{
|
||
var dir = MakeTempDir();
|
||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||
dir, shard: false, create: false,
|
||
deleteType: JwtDeleteType.NoDelete,
|
||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||
limit: 2,
|
||
evictOnLimit: true,
|
||
ttl: TimeSpan.Zero,
|
||
changeNotification: null);
|
||
|
||
var key1 = NextKey();
|
||
var key2 = NextKey();
|
||
var key3 = NextKey();
|
||
|
||
CreateTestAccount(store, key1, 1000);
|
||
CountJwtFiles(dir).ShouldBe(1);
|
||
CreateTestAccount(store, key2, 1000);
|
||
CountJwtFiles(dir).ShouldBe(2);
|
||
|
||
// Adding key3 should evict key1 (oldest).
|
||
CreateTestAccount(store, key3, 1000);
|
||
CountJwtFiles(dir).ShouldBe(2);
|
||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeFalse();
|
||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
|
||
|
||
// Update key2 → moves it to MRU. key3 becomes LRU.
|
||
CreateTestAccount(store, key2, 1000);
|
||
CountJwtFiles(dir).ShouldBe(2);
|
||
|
||
// Recreate key1 (which was evicted) → evicts key3.
|
||
CreateTestAccount(store, key1, 1); // expires in 1 s
|
||
CountJwtFiles(dir).ShouldBe(2);
|
||
File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeFalse();
|
||
|
||
// Let key1 expire (1 s + 1 s buffer for rounding).
|
||
await Task.Delay(2200);
|
||
CountJwtFiles(dir).ShouldBe(1);
|
||
File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeFalse();
|
||
|
||
// Recreate key3 — no eviction needed, slot is free.
|
||
CreateTestAccount(store, key3, 1000);
|
||
CountJwtFiles(dir).ShouldBe(2);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// T:291 — TestReload
|
||
// -------------------------------------------------------------------------
|
||
|
||
[Fact] // T:291
|
||
public void Reload_DetectsFilesAddedAndRemoved()
|
||
{
|
||
var dir = MakeTempDir();
|
||
var notificationChan = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||
|
||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||
dir, shard: false, create: false,
|
||
deleteType: JwtDeleteType.NoDelete,
|
||
expireCheck: TimeSpan.FromMilliseconds(100),
|
||
limit: 2,
|
||
evictOnLimit: true,
|
||
ttl: TimeSpan.Zero,
|
||
changeNotification: pk => notificationChan.Enqueue(pk));
|
||
|
||
CountJwtFiles(dir).ShouldBe(0);
|
||
var emptyHash = new byte[32];
|
||
store.Hash().ShouldBe(emptyHash);
|
||
|
||
var files = new List<string>();
|
||
|
||
// Add 5 accounts by writing to disk directly, then Reload().
|
||
for (var i = 0; i < 5; i++)
|
||
{
|
||
var key = NextKey();
|
||
var exp = DateTimeOffset.UtcNow.AddSeconds(10000).ToUnixTimeSeconds();
|
||
var jwt = MakeFakeJwt(exp);
|
||
var path = Path.Combine(dir, key + ".jwt");
|
||
File.WriteAllText(path, jwt);
|
||
files.Add(path);
|
||
|
||
store.Reload();
|
||
|
||
// Wait briefly for notification.
|
||
var deadline = DateTime.UtcNow.AddMilliseconds(500);
|
||
while (notificationChan.IsEmpty && DateTime.UtcNow < deadline)
|
||
Thread.Sleep(10);
|
||
notificationChan.TryDequeue(out _);
|
||
|
||
CountJwtFiles(dir).ShouldBe(i + 1);
|
||
store.Hash().ShouldNotBe(emptyHash);
|
||
|
||
var packed = store.Pack(-1);
|
||
packed.Split('\n').Length.ShouldBe(i + 1);
|
||
}
|
||
|
||
// Now remove files one by one.
|
||
foreach (var f in files)
|
||
{
|
||
var hash = store.Hash();
|
||
hash.ShouldNotBe(emptyHash);
|
||
File.Delete(f);
|
||
store.Reload();
|
||
CountJwtFiles(dir).ShouldBe(files.Count - files.IndexOf(f) - 1);
|
||
}
|
||
|
||
store.Hash().ShouldBe(emptyHash);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// T:292 — TestExpirationUpdate
|
||
// -------------------------------------------------------------------------
|
||
|
||
[Fact] // T:292
|
||
public async Task ExpirationUpdate_UpdatingExpirationExtendsTTL()
|
||
{
|
||
var dir = MakeTempDir();
|
||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||
dir, shard: false, create: false,
|
||
deleteType: JwtDeleteType.NoDelete,
|
||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||
limit: 10,
|
||
evictOnLimit: true,
|
||
ttl: TimeSpan.Zero,
|
||
changeNotification: null);
|
||
|
||
var key = NextKey();
|
||
var h = store.Hash();
|
||
|
||
// Save account with no expiry.
|
||
CreateTestAccount(store, key, 0);
|
||
var nh = store.Hash();
|
||
nh.ShouldNotBe(h);
|
||
h = nh;
|
||
|
||
await Task.Delay(1500);
|
||
CountJwtFiles(dir).ShouldBe(1); // should NOT have expired (no exp claim)
|
||
|
||
// Save same account with 2-second expiry.
|
||
CreateTestAccount(store, key, 2);
|
||
nh = store.Hash();
|
||
nh.ShouldNotBe(h);
|
||
h = nh;
|
||
|
||
await Task.Delay(1500);
|
||
CountJwtFiles(dir).ShouldBe(1); // not expired yet
|
||
|
||
// Save with no expiry again — resets expiry on that account.
|
||
CreateTestAccount(store, key, 0);
|
||
nh = store.Hash();
|
||
nh.ShouldNotBe(h);
|
||
h = nh;
|
||
|
||
await Task.Delay(1500);
|
||
CountJwtFiles(dir).ShouldBe(1); // still NOT expired
|
||
|
||
// Now save with 1-second expiry.
|
||
CreateTestAccount(store, key, 1);
|
||
nh = store.Hash();
|
||
nh.ShouldNotBe(h);
|
||
|
||
await Task.Delay(1500);
|
||
CountJwtFiles(dir).ShouldBe(0); // should be expired now
|
||
|
||
var empty = new byte[32];
|
||
store.Hash().ShouldBe(empty);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// T:293 — TestTTL
|
||
// -------------------------------------------------------------------------
|
||
|
||
[Fact] // T:293
|
||
public async Task TTL_AccessResetsExpirationOnStore()
|
||
{
|
||
var dir = MakeTempDir();
|
||
var key = NextKey();
|
||
|
||
// TTL = 200 ms. Each access (Load or Save) should reset expiry.
|
||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||
dir, shard: false, create: false,
|
||
deleteType: JwtDeleteType.NoDelete,
|
||
expireCheck: TimeSpan.FromMilliseconds(50),
|
||
limit: 10,
|
||
evictOnLimit: true,
|
||
ttl: TimeSpan.FromMilliseconds(200),
|
||
changeNotification: null);
|
||
|
||
CreateTestAccount(store, key, 0);
|
||
CountJwtFiles(dir).ShouldBe(1);
|
||
|
||
// Access every 110 ms — should prevent expiration.
|
||
for (var i = 0; i < 4; i++)
|
||
{
|
||
await Task.Delay(110);
|
||
store.LoadAcc(key); // TTL reset via Load
|
||
CountJwtFiles(dir).ShouldBe(1);
|
||
}
|
||
|
||
// Stop accessing — wait for expiration.
|
||
var deadline = DateTime.UtcNow.AddSeconds(3);
|
||
while (DateTime.UtcNow < deadline)
|
||
{
|
||
await Task.Delay(50);
|
||
if (CountJwtFiles(dir) == 0)
|
||
return; // expired as expected
|
||
}
|
||
|
||
Assert.Fail("JWT should have expired by now via TTL");
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// T:294 — TestRemove
|
||
// -------------------------------------------------------------------------
|
||
|
||
[Fact] // T:294
|
||
public void Remove_RespectsDeleteType()
|
||
{
|
||
foreach (var (deleteType, expectedJwt, expectedDeleted) in new[]
|
||
{
|
||
(JwtDeleteType.HardDelete, 0, 0),
|
||
(JwtDeleteType.RenameDeleted, 0, 1),
|
||
(JwtDeleteType.NoDelete, 1, 0),
|
||
})
|
||
{
|
||
var dir = MakeTempDir();
|
||
using var store = DirJwtStore.NewExpiringDirJwtStore(
|
||
dir, shard: false, create: false,
|
||
deleteType: deleteType,
|
||
expireCheck: TimeSpan.Zero,
|
||
limit: 10,
|
||
evictOnLimit: true,
|
||
ttl: TimeSpan.Zero,
|
||
changeNotification: null);
|
||
|
||
var key = NextKey();
|
||
CreateTestAccount(store, key, 0);
|
||
CountJwtFiles(dir).ShouldBe(1, $"deleteType={deleteType}: should have 1 jwt before delete");
|
||
|
||
// For HardDelete and RenameDeleted the store must allow Delete.
|
||
// For NoDelete, Delete should throw.
|
||
if (deleteType == JwtDeleteType.NoDelete)
|
||
{
|
||
Should.Throw<InvalidOperationException>(() => store.Delete(key),
|
||
$"deleteType={deleteType}: should throw on delete");
|
||
}
|
||
else
|
||
{
|
||
store.Delete(key);
|
||
}
|
||
|
||
// Count .jwt files (not .jwt.deleted).
|
||
var jwtFiles = Directory.GetFiles(dir, "*.jwt", SearchOption.AllDirectories)
|
||
.Count(f => !f.EndsWith(".jwt.deleted", StringComparison.Ordinal));
|
||
jwtFiles.ShouldBe(expectedJwt, $"deleteType={deleteType}: unexpected jwt count");
|
||
|
||
// Count .jwt.deleted files.
|
||
var deletedFiles = Directory.GetFiles(dir, "*.jwt.deleted", SearchOption.AllDirectories).Length;
|
||
deletedFiles.ShouldBe(expectedDeleted, $"deleteType={deleteType}: unexpected deleted count");
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// T:295 — TestNotificationOnPack
|
||
// -------------------------------------------------------------------------
|
||
|
||
[Fact] // T:295
|
||
public void NotificationOnPack_MergeFiresChangedCallback()
|
||
{
|
||
// Pre-populate a store with 4 accounts, pack it, then Merge into new stores.
|
||
// Each Merge should fire the change notification for every key.
|
||
const int JwtCount = 4;
|
||
var infDur = TimeSpan.FromDays(49); // "effectively infinite" (Timer max ≈ 49.7 days; TimeSpan.MaxValue/2 exceeds it)
|
||
var dirPack = MakeTempDir();
|
||
var keys = new string[JwtCount];
|
||
var jwts = new string[JwtCount];
|
||
|
||
var notifications = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||
using var packStore = DirJwtStore.NewExpiringDirJwtStore(
|
||
dirPack, shard: false, create: false,
|
||
deleteType: JwtDeleteType.NoDelete,
|
||
expireCheck: infDur,
|
||
limit: 0,
|
||
evictOnLimit: true,
|
||
ttl: TimeSpan.Zero,
|
||
changeNotification: pk => notifications.Enqueue(pk));
|
||
|
||
for (var i = 0; i < JwtCount; i++)
|
||
{
|
||
keys[i] = NextKey();
|
||
jwts[i] = MakeFakeJwt(0); // no expiry
|
||
packStore.SaveAcc(keys[i], jwts[i]);
|
||
}
|
||
|
||
// Drain initial notifications.
|
||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||
while (notifications.Count < JwtCount && DateTime.UtcNow < deadline)
|
||
Thread.Sleep(10);
|
||
while (notifications.TryDequeue(out _)) { }
|
||
|
||
var msg = packStore.Pack(-1);
|
||
var hash = packStore.Hash();
|
||
|
||
// Merge into new stores (sharded and unsharded).
|
||
foreach (var shard in new[] { true, false, true, false })
|
||
{
|
||
var dirMerge = MakeTempDir();
|
||
var mergeNotifications = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||
using var mergeStore = DirJwtStore.NewExpiringDirJwtStore(
|
||
dirMerge, shard: shard, create: false,
|
||
deleteType: JwtDeleteType.NoDelete,
|
||
expireCheck: infDur,
|
||
limit: 0,
|
||
evictOnLimit: true,
|
||
ttl: TimeSpan.Zero,
|
||
changeNotification: pk => mergeNotifications.Enqueue(pk));
|
||
|
||
mergeStore.Merge(msg);
|
||
CountJwtFiles(dirMerge).ShouldBe(JwtCount);
|
||
|
||
// Hashes must match.
|
||
packStore.Hash().ShouldBe(hash);
|
||
|
||
// Wait for JwtCount notifications.
|
||
deadline = DateTime.UtcNow.AddSeconds(2);
|
||
while (mergeNotifications.Count < JwtCount && DateTime.UtcNow < deadline)
|
||
Thread.Sleep(10);
|
||
mergeNotifications.Count.ShouldBeGreaterThanOrEqualTo(JwtCount);
|
||
|
||
// Double-merge should produce no extra file changes.
|
||
while (mergeNotifications.TryDequeue(out _)) { }
|
||
mergeStore.Merge(msg);
|
||
CountJwtFiles(dirMerge).ShouldBe(JwtCount);
|
||
Thread.Sleep(50);
|
||
mergeNotifications.IsEmpty.ShouldBeTrue("no new notifications on re-merge of identical JWTs");
|
||
|
||
msg = mergeStore.Pack(-1);
|
||
}
|
||
|
||
// All original JWTs can still be loaded from the last pack.
|
||
for (var i = 0; i < JwtCount; i++)
|
||
{
|
||
var found = msg.Contains(keys[i] + "|" + jwts[i]);
|
||
found.ShouldBeTrue($"key {keys[i]} should be in packed message");
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// T:296 — TestNotificationOnPackWalk
|
||
// -------------------------------------------------------------------------
|
||
|
||
[Fact] // T:296
|
||
public void NotificationOnPackWalk_PropagatesAcrossChainOfStores()
|
||
{
|
||
const int StoreCnt = 5;
|
||
const int KeyCnt = 50;
|
||
const int IterCnt = 4; // reduced from Go's 8 to keep test fast
|
||
var infDur = TimeSpan.FromDays(49); // "effectively infinite" (Timer max ≈ 49.7 days; TimeSpan.MaxValue/2 exceeds it)
|
||
|
||
var stores = new DirJwtStore[StoreCnt];
|
||
var dirs = new string[StoreCnt];
|
||
try
|
||
{
|
||
for (var i = 0; i < StoreCnt; i++)
|
||
{
|
||
dirs[i] = MakeTempDir();
|
||
stores[i] = DirJwtStore.NewExpiringDirJwtStore(
|
||
dirs[i], shard: true, create: false,
|
||
deleteType: JwtDeleteType.NoDelete,
|
||
expireCheck: infDur,
|
||
limit: 0,
|
||
evictOnLimit: true,
|
||
ttl: TimeSpan.Zero,
|
||
changeNotification: null);
|
||
}
|
||
|
||
for (var iter = 0; iter < IterCnt; iter++)
|
||
{
|
||
// Fill store[0] with KeyCnt new accounts.
|
||
for (var j = 0; j < KeyCnt; j++)
|
||
{
|
||
var k = NextKey();
|
||
var jwt = MakeFakeJwt(0);
|
||
stores[0].SaveAcc(k, jwt);
|
||
}
|
||
|
||
// Propagate via PackWalk from store[n] → store[n+1].
|
||
for (var j = 0; j < StoreCnt - 1; j++)
|
||
{
|
||
stores[j].PackWalk(3, partial => stores[j + 1].Merge(partial));
|
||
}
|
||
|
||
// Verify all adjacent store hashes match.
|
||
for (var j = 0; j < StoreCnt - 1; j++)
|
||
{
|
||
stores[j].Hash().ShouldBe(stores[j + 1].Hash(),
|
||
$"stores[{j}] and stores[{j + 1}] should have matching hashes after iteration {iter}");
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
foreach (var s in stores) try { s?.Dispose(); } catch { /* best-effort */ }
|
||
}
|
||
}
|
||
}
|