feat: port session 11 — Accounts & Directory JWT Store
- Account: full Account class (200 features) with subject mappings, connection counting, export/import checks, expiration timers - DirJwtStore: directory-based JWT storage with sharding and expiry - AccountResolver: IAccountResolver, MemoryAccountResolver, UrlAccountResolver, DirAccountResolver, CacheDirAccountResolver - AccountTypes: all supporting types (AccountLimits, SConns, ExportMap, ImportMap, ServiceExport, StreamExport, ServiceLatency, etc.) - 34 unit tests (599 total), 234 features complete (IDs 150-349, 793-826)
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
// Copyright 2018-2026 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.
|
||||
//
|
||||
// Adapted from server/accounts_test.go and server/dirstore_test.go in the NATS server Go source.
|
||||
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests;
|
||||
|
||||
[Collection("AccountTests")]
|
||||
public sealed class AccountTests
|
||||
{
|
||||
// =========================================================================
|
||||
// Account Basic Tests
|
||||
// =========================================================================
|
||||
|
||||
// Test 1
|
||||
[Fact]
|
||||
public void NewAccount_SetsNameAndUnlimitedLimits()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
acc.Name.ShouldBe("foo");
|
||||
acc.MaxConnections.ShouldBe(-1);
|
||||
acc.MaxLeafNodes.ShouldBe(-1);
|
||||
}
|
||||
|
||||
// Test 2
|
||||
[Fact]
|
||||
public void ToString_ReturnsName()
|
||||
{
|
||||
var acc = Account.NewAccount("myaccount");
|
||||
|
||||
acc.ToString().ShouldBe(acc.Name);
|
||||
}
|
||||
|
||||
// Test 3
|
||||
[Fact]
|
||||
public void IsExpired_InitiallyFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
acc.IsExpired().ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Test 4
|
||||
[Fact]
|
||||
public void IsClaimAccount_NoJwt_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
// ClaimJwt defaults to empty string
|
||||
acc.IsClaimAccount().ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Test 5
|
||||
[Fact]
|
||||
public void NumConnections_Initial_IsZero()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
acc.NumConnections().ShouldBe(0);
|
||||
}
|
||||
|
||||
// Test 6
|
||||
[Fact]
|
||||
public void GetName_ReturnsName()
|
||||
{
|
||||
var acc = Account.NewAccount("thread-safe-name");
|
||||
|
||||
acc.GetName().ShouldBe("thread-safe-name");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Subject Mapping Tests
|
||||
// =========================================================================
|
||||
|
||||
// Test 7
|
||||
[Fact]
|
||||
public void AddMapping_ValidSubject_Succeeds()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var err = acc.AddMapping("foo", "bar");
|
||||
|
||||
err.ShouldBeNull();
|
||||
}
|
||||
|
||||
// Test 8
|
||||
[Fact]
|
||||
public void AddMapping_InvalidSubject_ReturnsError()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var err = acc.AddMapping("foo..bar", "x");
|
||||
|
||||
err.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// Test 9
|
||||
[Fact]
|
||||
public void RemoveMapping_ExistingMapping_ReturnsTrue()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
acc.AddMapping("foo", "bar").ShouldBeNull();
|
||||
|
||||
var removed = acc.RemoveMapping("foo");
|
||||
|
||||
removed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Test 10
|
||||
[Fact]
|
||||
public void RemoveMapping_NonExistentMapping_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var removed = acc.RemoveMapping("nonexistent");
|
||||
|
||||
removed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Test 11
|
||||
[Fact]
|
||||
public void HasMappings_AfterAdd_ReturnsTrue()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
acc.AddMapping("foo", "bar").ShouldBeNull();
|
||||
|
||||
acc.HasMappings().ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Test 12
|
||||
[Fact]
|
||||
public void HasMappings_AfterRemove_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
acc.AddMapping("foo", "bar").ShouldBeNull();
|
||||
acc.RemoveMapping("foo");
|
||||
|
||||
acc.HasMappings().ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Test 13
|
||||
[Fact]
|
||||
public void SelectMappedSubject_NoMapping_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var (dest, mapped) = acc.SelectMappedSubject("foo");
|
||||
|
||||
mapped.ShouldBeFalse();
|
||||
dest.ShouldBe("foo");
|
||||
}
|
||||
|
||||
// Test 14
|
||||
[Fact]
|
||||
public void SelectMappedSubject_SimpleMapping_ReturnsMappedDest()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
acc.AddMapping("foo", "bar").ShouldBeNull();
|
||||
|
||||
var (dest, mapped) = acc.SelectMappedSubject("foo");
|
||||
|
||||
mapped.ShouldBeTrue();
|
||||
dest.ShouldBe("bar");
|
||||
}
|
||||
|
||||
// Test 15
|
||||
[Fact]
|
||||
public void AddWeightedMappings_DuplicateDest_ReturnsError()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var err = acc.AddWeightedMappings("src",
|
||||
MapDest.New("dest1", 50),
|
||||
MapDest.New("dest1", 50)); // duplicate subject
|
||||
|
||||
err.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// Test 16
|
||||
[Fact]
|
||||
public void AddWeightedMappings_WeightOver100_ReturnsError()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var err = acc.AddWeightedMappings("src",
|
||||
MapDest.New("dest1", 101)); // weight exceeds 100
|
||||
|
||||
err.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// Test 17
|
||||
[Fact]
|
||||
public void AddWeightedMappings_TotalWeightOver100_ReturnsError()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
var err = acc.AddWeightedMappings("src",
|
||||
MapDest.New("dest1", 80),
|
||||
MapDest.New("dest2", 80)); // total = 160
|
||||
|
||||
err.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Connection Counting Tests
|
||||
// =========================================================================
|
||||
|
||||
// Test 18
|
||||
[Fact]
|
||||
public void NumLeafNodes_Initial_IsZero()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
acc.NumLeafNodes().ShouldBe(0);
|
||||
}
|
||||
|
||||
// Test 19
|
||||
[Fact]
|
||||
public void MaxTotalConnectionsReached_UnlimitedAccount_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
// MaxConnections is -1 (unlimited) by default
|
||||
|
||||
acc.MaxTotalConnectionsReached().ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Test 20
|
||||
[Fact]
|
||||
public void MaxTotalLeafNodesReached_UnlimitedAccount_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
// MaxLeafNodes is -1 (unlimited) by default
|
||||
|
||||
acc.MaxTotalLeafNodesReached().ShouldBeFalse();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Export Service Tests
|
||||
// =========================================================================
|
||||
|
||||
// Test 21
|
||||
[Fact]
|
||||
public void IsExportService_NoExports_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
acc.IsExportService("my.service").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Test 22
|
||||
[Fact]
|
||||
public void IsExportServiceTracking_NoExports_ReturnsFalse()
|
||||
{
|
||||
var acc = Account.NewAccount("foo");
|
||||
|
||||
acc.IsExportServiceTracking("my.service").ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DirJwtStore Tests
|
||||
// =========================================================================
|
||||
|
||||
[Collection("AccountTests")]
|
||||
public sealed class DirJwtStoreTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _tempDirs = [];
|
||||
|
||||
private string MakeTempDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
Directory.CreateDirectory(dir);
|
||||
_tempDirs.Add(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var dir in _tempDirs)
|
||||
{
|
||||
try { Directory.Delete(dir, true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Test 23
|
||||
[Fact]
|
||||
public void DirJwtStore_WriteAndRead_Succeeds()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||
|
||||
const string key = "AAAAAAAAAA"; // minimum 2-char key
|
||||
const string jwt = "header.payload.signature";
|
||||
|
||||
store.SaveAcc(key, jwt);
|
||||
var loaded = store.LoadAcc(key);
|
||||
|
||||
loaded.ShouldBe(jwt);
|
||||
}
|
||||
|
||||
// Test 24
|
||||
[Fact]
|
||||
public void DirJwtStore_ShardedWriteAndRead_Succeeds()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewDirJwtStore(dir, shard: true, create: false);
|
||||
|
||||
var keys = new[] { "ACCTKEY001", "ACCTKEY002", "ACCTKEY003" };
|
||||
foreach (var k in keys)
|
||||
{
|
||||
store.SaveAcc(k, $"jwt.for.{k}");
|
||||
}
|
||||
|
||||
foreach (var k in keys)
|
||||
{
|
||||
store.LoadAcc(k).ShouldBe($"jwt.for.{k}");
|
||||
}
|
||||
}
|
||||
|
||||
// Test 25
|
||||
[Fact]
|
||||
public void DirJwtStore_EmptyKey_ReturnsError()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||
|
||||
// LoadAcc with key shorter than 2 chars should throw
|
||||
Should.Throw<Exception>(() => store.LoadAcc(""));
|
||||
|
||||
// SaveAcc with key shorter than 2 chars should throw
|
||||
Should.Throw<Exception>(() => store.SaveAcc("", "some.jwt"));
|
||||
}
|
||||
|
||||
// Test 26
|
||||
[Fact]
|
||||
public void DirJwtStore_MissingKey_ReturnsError()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||
|
||||
Should.Throw<FileNotFoundException>(() => store.LoadAcc("NONEXISTENT_KEY"));
|
||||
}
|
||||
|
||||
// Test 27
|
||||
[Fact]
|
||||
public void DirJwtStore_Pack_ContainsSavedJwts()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||
|
||||
store.SaveAcc("ACCTKEYAAA", "jwt1.data.sig");
|
||||
store.SaveAcc("ACCTKEYBBB", "jwt2.data.sig");
|
||||
|
||||
var packed = store.Pack(-1);
|
||||
|
||||
packed.ShouldContain("ACCTKEYAAA|jwt1.data.sig");
|
||||
packed.ShouldContain("ACCTKEYBBB|jwt2.data.sig");
|
||||
}
|
||||
|
||||
// Test 28
|
||||
[Fact]
|
||||
public void DirJwtStore_Merge_AddsNewEntries()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||
|
||||
// Pack format: key|jwt lines separated by newline
|
||||
var packData = "ACCTKEYMERGE|merged.jwt.value";
|
||||
store.Merge(packData);
|
||||
|
||||
var loaded = store.LoadAcc("ACCTKEYMERGE");
|
||||
loaded.ShouldBe("merged.jwt.value");
|
||||
}
|
||||
|
||||
// Test 29
|
||||
[Fact]
|
||||
public void DirJwtStore_ReadOnly_Prevents_Write()
|
||||
{
|
||||
var dir = MakeTempDir();
|
||||
// Write a file first so the dir is valid
|
||||
var writeable = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||
writeable.SaveAcc("ACCTKEYRO", "original.jwt");
|
||||
writeable.Dispose();
|
||||
|
||||
// Open as immutable
|
||||
using var readOnly = DirJwtStore.NewImmutableDirJwtStore(dir, shard: false);
|
||||
|
||||
readOnly.IsReadOnly().ShouldBeTrue();
|
||||
Should.Throw<InvalidOperationException>(() => readOnly.SaveAcc("ACCTKEYRO", "new.jwt"));
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MemoryAccountResolver Tests
|
||||
// =========================================================================
|
||||
|
||||
[Collection("AccountTests")]
|
||||
public sealed class MemoryAccountResolverTests
|
||||
{
|
||||
// Test 30
|
||||
[Fact]
|
||||
public async Task MemoryAccountResolver_StoreAndFetch_Roundtrip()
|
||||
{
|
||||
var resolver = new MemoryAccountResolver();
|
||||
const string key = "MYACCOUNTKEY";
|
||||
const string jwt = "header.payload.sig";
|
||||
|
||||
await resolver.StoreAsync(key, jwt);
|
||||
var fetched = await resolver.FetchAsync(key);
|
||||
|
||||
fetched.ShouldBe(jwt);
|
||||
}
|
||||
|
||||
// Test 31
|
||||
[Fact]
|
||||
public async Task MemoryAccountResolver_Fetch_MissingKey_Throws()
|
||||
{
|
||||
var resolver = new MemoryAccountResolver();
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => resolver.FetchAsync("DOESNOTEXIST"));
|
||||
}
|
||||
|
||||
// Test 32
|
||||
[Fact]
|
||||
public void MemoryAccountResolver_IsReadOnly_ReturnsFalse()
|
||||
{
|
||||
var resolver = new MemoryAccountResolver();
|
||||
|
||||
resolver.IsReadOnly().ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// UrlAccountResolver Tests
|
||||
// =========================================================================
|
||||
|
||||
[Collection("AccountTests")]
|
||||
public sealed class UrlAccountResolverTests
|
||||
{
|
||||
// Test 33
|
||||
[Fact]
|
||||
public void UrlAccountResolver_NormalizesTrailingSlash()
|
||||
{
|
||||
// Two constructors: one with slash, one without.
|
||||
// We verify construction doesn't throw and the resolver is usable.
|
||||
// (We cannot inspect _url directly since it's private, but we can
|
||||
// infer correctness via IsReadOnly and lack of constructor exception.)
|
||||
var resolverNoSlash = new UrlAccountResolver("http://localhost:9090");
|
||||
var resolverWithSlash = new UrlAccountResolver("http://localhost:9090/");
|
||||
|
||||
// Both should construct without error and have the same observable behaviour.
|
||||
resolverNoSlash.IsReadOnly().ShouldBeTrue();
|
||||
resolverWithSlash.IsReadOnly().ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Test 34
|
||||
[Fact]
|
||||
public void UrlAccountResolver_IsReadOnly_ReturnsTrue()
|
||||
{
|
||||
var resolver = new UrlAccountResolver("http://localhost:9090");
|
||||
|
||||
resolver.IsReadOnly().ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user