fix: dead jobengine route path rewriting + legacy endpoint delegation
- Fix PacksRegistry route: rewrite /jobengine/registry/packs → /packs on target - Fix first-signal route: delegate to real handler instead of 501 stub - Release-orchestrator persistence extraction progress Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
public class TenantAwareCryptoProviderRegistryTests
|
||||
{
|
||||
private const string TenantA = "tenant-a";
|
||||
private const string TenantB = "tenant-b";
|
||||
|
||||
private static readonly ILogger Logger = NullLoggerFactory.Instance.CreateLogger<TenantAwareCryptoProviderRegistry>();
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private sealed class FakePreferenceProvider : ITenantCryptoPreferenceProvider
|
||||
{
|
||||
private readonly Dictionary<string, IReadOnlyList<string>> preferences = new(StringComparer.OrdinalIgnoreCase);
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public void Set(string tenantId, string scope, IReadOnlyList<string> providers)
|
||||
{
|
||||
preferences[$"{tenantId}:{scope}"] = providers;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetPreferredProvidersAsync(
|
||||
string tenantId, string algorithmScope = "*", CancellationToken cancellationToken = default)
|
||||
{
|
||||
CallCount++;
|
||||
var key = $"{tenantId}:{algorithmScope}";
|
||||
return Task.FromResult(
|
||||
preferences.TryGetValue(key, out var list) ? list : (IReadOnlyList<string>)Array.Empty<string>());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeProvider : ICryptoProvider
|
||||
{
|
||||
private readonly HashSet<(CryptoCapability, string)> supported = new();
|
||||
|
||||
public FakeProvider(string name) => Name = name;
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public FakeProvider WithSupport(CryptoCapability capability, string algorithm)
|
||||
{
|
||||
supported.Add((capability, algorithm));
|
||||
return this;
|
||||
}
|
||||
|
||||
public bool Supports(CryptoCapability capability, string algorithmId) => supported.Contains((capability, algorithmId));
|
||||
|
||||
public IPasswordHasher GetPasswordHasher(string algorithmId) => throw new NotSupportedException();
|
||||
|
||||
public ICryptoHasher GetHasher(string algorithmId) => new StubHasher(algorithmId);
|
||||
|
||||
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
|
||||
=> new StubSigner(Name, keyReference.KeyId, algorithmId);
|
||||
|
||||
public void UpsertSigningKey(CryptoSigningKey signingKey) { }
|
||||
public bool RemoveSigningKey(string keyId) => false;
|
||||
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys() => Array.Empty<CryptoSigningKey>();
|
||||
}
|
||||
|
||||
private sealed class StubHasher : ICryptoHasher
|
||||
{
|
||||
public StubHasher(string algorithmId) => AlgorithmId = algorithmId;
|
||||
public string AlgorithmId { get; }
|
||||
public byte[] ComputeHash(ReadOnlySpan<byte> data) => Array.Empty<byte>();
|
||||
public string ComputeHashHex(ReadOnlySpan<byte> data) => "";
|
||||
}
|
||||
|
||||
private sealed class StubSigner : ICryptoSigner
|
||||
{
|
||||
public StubSigner(string provider, string keyId, string algorithmId)
|
||||
{
|
||||
Provider = provider;
|
||||
KeyId = keyId;
|
||||
AlgorithmId = algorithmId;
|
||||
}
|
||||
|
||||
public string Provider { get; }
|
||||
public string KeyId { get; }
|
||||
public string AlgorithmId { get; }
|
||||
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken ct = default)
|
||||
=> ValueTask.FromResult(Array.Empty<byte>());
|
||||
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken ct = default)
|
||||
=> ValueTask.FromResult(true);
|
||||
public Microsoft.IdentityModel.Tokens.JsonWebKey ExportPublicJsonWebKey() => new();
|
||||
}
|
||||
|
||||
private static CryptoProviderRegistry BuildInnerRegistry(params FakeProvider[] providers)
|
||||
{
|
||||
return new CryptoProviderRegistry(providers);
|
||||
}
|
||||
|
||||
private static TenantAwareCryptoProviderRegistry Build(
|
||||
ICryptoProviderRegistry inner,
|
||||
FakePreferenceProvider preferences,
|
||||
Func<string?> tenantAccessor,
|
||||
TimeSpan? cacheTtl = null)
|
||||
{
|
||||
return new TenantAwareCryptoProviderRegistry(
|
||||
inner,
|
||||
preferences,
|
||||
tenantAccessor,
|
||||
TimeProvider.System,
|
||||
Logger,
|
||||
cacheTtl ?? TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// ResolveOrThrow
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveOrThrow_NoTenantContext_FallsBackToInnerRegistry()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
|
||||
var inner = BuildInnerRegistry(providerA, providerB);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
var registry = Build(inner, prefs, () => null);
|
||||
|
||||
var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
|
||||
// No tenant context => inner registry default ordering => providerA (first registered)
|
||||
Assert.Same(providerA, resolved);
|
||||
Assert.Equal(0, prefs.CallCount); // Should not even query preferences
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveOrThrow_WithTenantPreference_ReturnsPreferredProvider()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
|
||||
var inner = BuildInnerRegistry(providerA, providerB);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
prefs.Set(TenantA, "*", new[] { "providerB" });
|
||||
|
||||
var registry = Build(inner, prefs, () => TenantA);
|
||||
|
||||
var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
|
||||
Assert.Same(providerB, resolved);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveOrThrow_TenantPreferenceProviderEmpty_FallsBackToDefault()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
|
||||
var inner = BuildInnerRegistry(providerA, providerB);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
// No preferences set for TenantA => empty list
|
||||
|
||||
var registry = Build(inner, prefs, () => TenantA);
|
||||
|
||||
var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
|
||||
// Empty preferences => fallback to inner default => providerA
|
||||
Assert.Same(providerA, resolved);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveOrThrow_TenantAccessorThrows_FallsBackToDefault()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
var inner = BuildInnerRegistry(providerA);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
|
||||
var registry = Build(inner, prefs, () => throw new InvalidOperationException("no tenant context"));
|
||||
|
||||
var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
|
||||
Assert.Same(providerA, resolved);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveOrThrow_PreferredProviderDoesNotSupportAlgorithm_TriesNextThenFallback()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Rs256);
|
||||
var providerC = new FakeProvider("providerC").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
|
||||
var inner = BuildInnerRegistry(providerA, providerB, providerC);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
// Tenant prefers providerB first, then providerC; providerB doesn't support Es256
|
||||
prefs.Set(TenantA, "*", new[] { "providerB", "providerC" });
|
||||
|
||||
var registry = Build(inner, prefs, () => TenantA);
|
||||
|
||||
var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
|
||||
// providerB doesn't support Es256 => providerC does => resolved to providerC
|
||||
Assert.Same(providerC, resolved);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// ResolveSigner
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveSigner_ExplicitPreferredProvider_OverridesTenantPreference()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
|
||||
var inner = BuildInnerRegistry(providerA, providerB);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
prefs.Set(TenantA, "*", new[] { "providerB" });
|
||||
|
||||
var registry = Build(inner, prefs, () => TenantA);
|
||||
|
||||
// Explicit preferredProvider "providerA" should override tenant preference "providerB"
|
||||
var result = registry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
SignatureAlgorithms.Es256,
|
||||
new CryptoKeyReference("key-1"),
|
||||
preferredProvider: "providerA");
|
||||
|
||||
Assert.Equal("providerA", result.ProviderName);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveSigner_NoExplicitPreferred_UsesTenantPreference()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
|
||||
var inner = BuildInnerRegistry(providerA, providerB);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
prefs.Set(TenantA, "*", new[] { "providerB" });
|
||||
|
||||
var registry = Build(inner, prefs, () => TenantA);
|
||||
|
||||
var result = registry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
SignatureAlgorithms.Es256,
|
||||
new CryptoKeyReference("key-1"));
|
||||
|
||||
Assert.Equal("providerB", result.ProviderName);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// ResolveHasher
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveHasher_NoTenantContext_FallsBackToDefault()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
|
||||
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
|
||||
|
||||
var inner = BuildInnerRegistry(providerA, providerB);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
var registry = Build(inner, prefs, () => null);
|
||||
|
||||
var result = registry.ResolveHasher(HashAlgorithms.Sha256);
|
||||
|
||||
Assert.Equal("providerA", result.ProviderName);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveHasher_WithTenantPreference_ReturnsPreferredProvider()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
|
||||
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
|
||||
|
||||
var inner = BuildInnerRegistry(providerA, providerB);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
prefs.Set(TenantA, "*", new[] { "providerB" });
|
||||
|
||||
var registry = Build(inner, prefs, () => TenantA);
|
||||
|
||||
var result = registry.ResolveHasher(HashAlgorithms.Sha256);
|
||||
|
||||
Assert.Equal("providerB", result.ProviderName);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveHasher_ExplicitPreferred_OverridesTenantPreference()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
|
||||
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.ContentHashing, HashAlgorithms.Sha256);
|
||||
|
||||
var inner = BuildInnerRegistry(providerA, providerB);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
prefs.Set(TenantA, "*", new[] { "providerB" });
|
||||
|
||||
var registry = Build(inner, prefs, () => TenantA);
|
||||
|
||||
var result = registry.ResolveHasher(HashAlgorithms.Sha256, preferredProvider: "providerA");
|
||||
|
||||
Assert.Equal("providerA", result.ProviderName);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Caching
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void PreferencesCached_SecondCallDoesNotQueryProvider()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
|
||||
var inner = BuildInnerRegistry(providerA, providerB);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
prefs.Set(TenantA, "*", new[] { "providerB" });
|
||||
|
||||
var registry = Build(inner, prefs, () => TenantA, cacheTtl: TimeSpan.FromMinutes(5));
|
||||
|
||||
// First call: cache miss -> hits provider
|
||||
registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
Assert.Equal(1, prefs.CallCount);
|
||||
|
||||
// Second call: cache hit -> no additional provider query
|
||||
registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
Assert.Equal(1, prefs.CallCount);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Providers property delegates to inner
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Providers_DelegatesToInnerRegistry()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
var inner = BuildInnerRegistry(providerA);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
|
||||
var registry = Build(inner, prefs, () => null);
|
||||
|
||||
Assert.Single(registry.Providers);
|
||||
Assert.Contains(registry.Providers, p => p.Name == "providerA");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// TryResolve delegates to inner
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryResolve_DelegatesToInnerRegistry()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
var inner = BuildInnerRegistry(providerA);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
|
||||
var registry = Build(inner, prefs, () => TenantA);
|
||||
|
||||
Assert.True(registry.TryResolve("providerA", out var resolved));
|
||||
Assert.Same(providerA, resolved);
|
||||
|
||||
Assert.False(registry.TryResolve("nonexistent", out _));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Multi-tenant isolation
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DifferentTenants_ResolveDifferentProviders()
|
||||
{
|
||||
var providerA = new FakeProvider("providerA").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
var providerB = new FakeProvider("providerB").WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
|
||||
var inner = BuildInnerRegistry(providerA, providerB);
|
||||
var prefs = new FakePreferenceProvider();
|
||||
prefs.Set(TenantA, "*", new[] { "providerA" });
|
||||
prefs.Set(TenantB, "*", new[] { "providerB" });
|
||||
|
||||
string currentTenant = TenantA;
|
||||
var registry = Build(inner, prefs, () => currentTenant);
|
||||
|
||||
// Tenant A resolves providerA
|
||||
var resolvedA = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
Assert.Same(providerA, resolvedA);
|
||||
|
||||
// Switch to Tenant B: resolves providerB
|
||||
currentTenant = TenantB;
|
||||
var resolvedB = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
|
||||
Assert.Same(providerB, resolvedB);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user