// 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; /// /// Unit tests for expiration, limits, LRU eviction, /// reload, TTL and notification behaviour. /// Mirrors server/dirstore_test.go tests 285–296. /// [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 _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 // ------------------------------------------------------------------------- /// /// Builds a minimal fake JWT string: header.payload.signature /// where the payload contains "exp", "iat" and "jti" claims. /// 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}"; } /// /// Rounds a to the nearest whole second, /// mirroring Go's time.Now().Round(time.Second). /// 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('/', '_'); } /// /// Creates and saves a test account JWT in the store. /// == 0 means no expiration. /// Returns the saved JWT string. /// 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; } /// /// Counts non-deleted .jwt files in recursively. /// 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(() => 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(); 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(); // 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(() => 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(); 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(); 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 */ } } } }