479 lines
13 KiB
C#
479 lines
13 KiB
C#
// 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 partial 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();
|
|
}
|
|
}
|