fix(p7-09): move DirectoryStoreTests to Accounts/, add missing PriorityPolicy test case
This commit is contained in:
@@ -0,0 +1,770 @@
|
||||
// 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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user