Files
natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/DirectoryStoreTests.cs

771 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 285296 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 285296.
/// </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 */ }
}
}
}