using System; using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Mongo2Go; using MongoDB.Driver; using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Plugin.Standard; using StellaOps.Authority.Plugin.Standard.Bootstrap; using StellaOps.Authority.Plugin.Standard.Storage; using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.Mongo.Stores; namespace StellaOps.Authority.Plugin.Standard.Tests; public class StandardPluginRegistrarTests { [Fact] public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser() { using var runner = MongoDbRunner.Start(singleNodeReplSet: true); var client = new MongoClient(runner.ConnectionString); var database = client.GetDatabase("registrar-tests"); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["passwordPolicy:minimumLength"] = "8", ["passwordPolicy:requireDigit"] = "false", ["passwordPolicy:requireSymbol"] = "false", ["lockout:enabled"] = "false", ["passwordHashing:memorySizeInKib"] = "8192", ["passwordHashing:iterations"] = "2", ["passwordHashing:parallelism"] = "1", ["bootstrapUser:username"] = "bootstrap", ["bootstrapUser:password"] = "Bootstrap1!", ["bootstrapUser:requirePasswordReset"] = "true" }) .Build(); var manifest = new AuthorityPluginManifest( "standard", "standard", true, typeof(StandardPluginRegistrar).Assembly.GetName().Name, typeof(StandardPluginRegistrar).Assembly.Location, new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Bootstrap, AuthorityPluginCapabilities.ClientProvisioning }, new Dictionary(), "standard.yaml"); var pluginContext = new AuthorityPluginContext(manifest, configuration); var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(database); services.AddSingleton(new InMemoryClientStore()); services.AddSingleton(new StubRevocationStore()); services.AddSingleton(TimeProvider.System); services.AddSingleton(new StubRevocationStore()); services.AddSingleton(TimeProvider.System); services.AddSingleton(new StubRevocationStore()); services.AddSingleton(TimeProvider.System); services.AddSingleton(new StubRevocationStore()); services.AddSingleton(TimeProvider.System); services.AddSingleton(new StubRevocationStore()); services.AddSingleton(TimeProvider.System); services.AddSingleton(TimeProvider.System); services.AddSingleton(TimeProvider.System); services.AddSingleton(new StubRevocationStore()); services.AddSingleton(new StubRevocationStore()); services.AddSingleton(new StubRevocationStore()); var registrar = new StandardPluginRegistrar(); registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration)); var provider = services.BuildServiceProvider(); var hostedServices = provider.GetServices(); foreach (var hosted in hostedServices) { if (hosted is StandardPluginBootstrapper bootstrapper) { await bootstrapper.StartAsync(CancellationToken.None); } } var plugin = provider.GetRequiredService(); Assert.Equal("standard", plugin.Type); Assert.True(plugin.Capabilities.SupportsPassword); Assert.False(plugin.Capabilities.SupportsMfa); Assert.True(plugin.Capabilities.SupportsClientProvisioning); var verification = await plugin.Credentials.VerifyPasswordAsync("bootstrap", "Bootstrap1!", CancellationToken.None); Assert.True(verification.Succeeded); Assert.True(verification.User?.RequiresPasswordReset); } [Fact] public void Register_LogsWarning_WhenPasswordPolicyWeaker() { using var runner = MongoDbRunner.Start(singleNodeReplSet: true); var client = new MongoClient(runner.ConnectionString); var database = client.GetDatabase("registrar-password-policy"); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["passwordPolicy:minimumLength"] = "6", ["passwordPolicy:requireUppercase"] = "false", ["passwordPolicy:requireLowercase"] = "false", ["passwordPolicy:requireDigit"] = "false", ["passwordPolicy:requireSymbol"] = "false" }) .Build(); var manifest = new AuthorityPluginManifest( "standard", "standard", true, typeof(StandardPluginRegistrar).Assembly.GetName().Name, typeof(StandardPluginRegistrar).Assembly.Location, new[] { AuthorityPluginCapabilities.Password }, new Dictionary(), "standard.yaml"); var pluginContext = new AuthorityPluginContext(manifest, configuration); var services = new ServiceCollection(); var loggerProvider = new CapturingLoggerProvider(); services.AddLogging(builder => builder.AddProvider(loggerProvider)); services.AddSingleton(database); services.AddSingleton(new InMemoryClientStore()); var registrar = new StandardPluginRegistrar(); registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration)); using var provider = services.BuildServiceProvider(); _ = provider.GetRequiredService(); Assert.Contains(loggerProvider.Entries, entry => entry.Level == LogLevel.Warning && entry.Category.Contains(typeof(StandardPluginRegistrar).FullName!, StringComparison.Ordinal) && entry.Message.Contains("weaker password policy", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Register_ForcesPasswordCapability_WhenManifestMissing() { using var runner = MongoDbRunner.Start(singleNodeReplSet: true); var client = new MongoClient(runner.ConnectionString); var database = client.GetDatabase("registrar-capabilities"); var configuration = new ConfigurationBuilder().Build(); var manifest = new AuthorityPluginManifest( "standard", "standard", true, typeof(StandardPluginRegistrar).Assembly.GetName().Name, typeof(StandardPluginRegistrar).Assembly.Location, Array.Empty(), new Dictionary(), "standard.yaml"); var pluginContext = new AuthorityPluginContext(manifest, configuration); var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(database); services.AddSingleton(new InMemoryClientStore()); services.AddSingleton(new StubRevocationStore()); services.AddSingleton(TimeProvider.System); var registrar = new StandardPluginRegistrar(); registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration)); using var provider = services.BuildServiceProvider(); var plugin = provider.GetRequiredService(); Assert.True(plugin.Capabilities.SupportsPassword); } [Fact] public void Register_Throws_WhenBootstrapConfigurationIncomplete() { using var runner = MongoDbRunner.Start(singleNodeReplSet: true); var client = new MongoClient(runner.ConnectionString); var database = client.GetDatabase("registrar-bootstrap-validation"); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["bootstrapUser:username"] = "bootstrap" }) .Build(); var manifest = new AuthorityPluginManifest( "standard", "standard", true, typeof(StandardPluginRegistrar).Assembly.GetName().Name, typeof(StandardPluginRegistrar).Assembly.Location, new[] { AuthorityPluginCapabilities.Password }, new Dictionary(), "standard.yaml"); var pluginContext = new AuthorityPluginContext(manifest, configuration); var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(database); services.AddSingleton(new InMemoryClientStore()); var registrar = new StandardPluginRegistrar(); registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration)); using var provider = services.BuildServiceProvider(); Assert.Throws(() => provider.GetRequiredService()); } [Fact] public void Register_NormalizesTokenSigningKeyDirectory() { using var runner = MongoDbRunner.Start(singleNodeReplSet: true); var client = new MongoClient(runner.ConnectionString); var database = client.GetDatabase("registrar-token-signing"); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["tokenSigning:keyDirectory"] = "../keys" }) .Build(); var configDir = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(configDir); try { var configPath = Path.Combine(configDir, "standard.yaml"); var manifest = new AuthorityPluginManifest( "standard", "standard", true, typeof(StandardPluginRegistrar).Assembly.GetName().Name, typeof(StandardPluginRegistrar).Assembly.Location, new[] { AuthorityPluginCapabilities.Password }, new Dictionary(), configPath); var pluginContext = new AuthorityPluginContext(manifest, configuration); var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(database); services.AddSingleton(new InMemoryClientStore()); var registrar = new StandardPluginRegistrar(); registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration)); using var provider = services.BuildServiceProvider(); var optionsMonitor = provider.GetRequiredService>(); var options = optionsMonitor.Get("standard"); var expected = Path.GetFullPath(Path.Combine(configDir, "../keys")); Assert.Equal(expected, options.TokenSigning.KeyDirectory); } finally { if (Directory.Exists(configDir)) { Directory.Delete(configDir, recursive: true); } } } } internal sealed record CapturedLogEntry(string Category, LogLevel Level, string Message); internal sealed class CapturingLoggerProvider : ILoggerProvider { public List Entries { get; } = new(); public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, Entries); public void Dispose() { } private sealed class CapturingLogger : ILogger { private readonly string category; private readonly List entries; public CapturingLogger(string category, List entries) { this.category = category; this.entries = entries; } public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { entries.Add(new CapturedLogEntry(category, logLevel, formatter(state, exception))); } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } } internal sealed class StubRevocationStore : IAuthorityRevocationStore { public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken) => ValueTask.CompletedTask; public ValueTask RemoveAsync(string category, string revocationId, CancellationToken cancellationToken) => ValueTask.FromResult(false); public ValueTask> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken) => ValueTask.FromResult>(Array.Empty()); } internal sealed class InMemoryClientStore : IAuthorityClientStore { private readonly Dictionary clients = new(StringComparer.OrdinalIgnoreCase); public ValueTask FindByClientIdAsync(string clientId, CancellationToken cancellationToken) { clients.TryGetValue(clientId, out var document); return ValueTask.FromResult(document); } public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken) { clients[document.ClientId] = document; return ValueTask.CompletedTask; } public ValueTask DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken) => ValueTask.FromResult(clients.Remove(clientId)); }