Initial commit (history squashed)
This commit is contained in:
		@@ -0,0 +1,66 @@
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using StellaOps.Authority.Plugins.Abstractions;
 | 
			
		||||
using StellaOps.Authority.Plugin.Standard.Storage;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
using Xunit;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
 | 
			
		||||
 | 
			
		||||
public class StandardClientProvisioningStoreTests
 | 
			
		||||
{
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public async Task CreateOrUpdateAsync_HashesSecretAndPersistsDocument()
 | 
			
		||||
    {
 | 
			
		||||
        var store = new TrackingClientStore();
 | 
			
		||||
        var provisioning = new StandardClientProvisioningStore("standard", store);
 | 
			
		||||
 | 
			
		||||
        var registration = new AuthorityClientRegistration(
 | 
			
		||||
            clientId: "bootstrap-client",
 | 
			
		||||
            confidential: true,
 | 
			
		||||
            displayName: "Bootstrap",
 | 
			
		||||
            clientSecret: "SuperSecret1!",
 | 
			
		||||
            allowedGrantTypes: new[] { "client_credentials" },
 | 
			
		||||
            allowedScopes: new[] { "scopeA" });
 | 
			
		||||
 | 
			
		||||
        var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
 | 
			
		||||
 | 
			
		||||
        Assert.True(result.Succeeded);
 | 
			
		||||
        Assert.True(store.Documents.TryGetValue("bootstrap-client", out var document));
 | 
			
		||||
        Assert.NotNull(document);
 | 
			
		||||
        Assert.Equal(AuthoritySecretHasher.ComputeHash("SuperSecret1!"), document!.SecretHash);
 | 
			
		||||
        Assert.Equal("standard", document.Plugin);
 | 
			
		||||
 | 
			
		||||
        var descriptor = await provisioning.FindByClientIdAsync("bootstrap-client", CancellationToken.None);
 | 
			
		||||
        Assert.NotNull(descriptor);
 | 
			
		||||
        Assert.Equal("bootstrap-client", descriptor!.ClientId);
 | 
			
		||||
        Assert.True(descriptor.Confidential);
 | 
			
		||||
        Assert.Contains("client_credentials", descriptor.AllowedGrantTypes);
 | 
			
		||||
        Assert.Contains("scopeA", descriptor.AllowedScopes);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed class TrackingClientStore : IAuthorityClientStore
 | 
			
		||||
    {
 | 
			
		||||
        public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
        public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
 | 
			
		||||
        {
 | 
			
		||||
            Documents.TryGetValue(clientId, out var document);
 | 
			
		||||
            return ValueTask.FromResult(document);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
 | 
			
		||||
        {
 | 
			
		||||
            Documents[document.ClientId] = document;
 | 
			
		||||
            return ValueTask.CompletedTask;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
 | 
			
		||||
        {
 | 
			
		||||
            var removed = Documents.Remove(clientId);
 | 
			
		||||
            return ValueTask.FromResult(removed);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,99 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.IO;
 | 
			
		||||
using StellaOps.Authority.Plugin.Standard;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
 | 
			
		||||
 | 
			
		||||
public class StandardPluginOptionsTests
 | 
			
		||||
{
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Validate_AllowsBootstrapWhenCredentialsProvided()
 | 
			
		||||
    {
 | 
			
		||||
        var options = new StandardPluginOptions
 | 
			
		||||
        {
 | 
			
		||||
            BootstrapUser = new BootstrapUserOptions
 | 
			
		||||
            {
 | 
			
		||||
                Username = "admin",
 | 
			
		||||
                Password = "Bootstrap1!",
 | 
			
		||||
                RequirePasswordReset = true
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        options.Validate("standard");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Validate_Throws_WhenBootstrapUserIncomplete()
 | 
			
		||||
    {
 | 
			
		||||
        var options = new StandardPluginOptions
 | 
			
		||||
        {
 | 
			
		||||
            BootstrapUser = new BootstrapUserOptions
 | 
			
		||||
            {
 | 
			
		||||
                Username = "admin",
 | 
			
		||||
                Password = null
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
 | 
			
		||||
        Assert.Contains("bootstrapUser", ex.Message, StringComparison.OrdinalIgnoreCase);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Validate_Throws_WhenLockoutWindowMinutesInvalid()
 | 
			
		||||
    {
 | 
			
		||||
        var options = new StandardPluginOptions
 | 
			
		||||
        {
 | 
			
		||||
            Lockout = new LockoutOptions
 | 
			
		||||
            {
 | 
			
		||||
                Enabled = true,
 | 
			
		||||
                MaxAttempts = 5,
 | 
			
		||||
                WindowMinutes = 0
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
 | 
			
		||||
        Assert.Contains("lockout.windowMinutes", ex.Message, StringComparison.OrdinalIgnoreCase);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Normalize_ResolvesRelativeTokenSigningDirectory()
 | 
			
		||||
    {
 | 
			
		||||
        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 options = new StandardPluginOptions
 | 
			
		||||
            {
 | 
			
		||||
                TokenSigning = { KeyDirectory = "../keys" }
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            options.Normalize(configPath);
 | 
			
		||||
 | 
			
		||||
            var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
 | 
			
		||||
            Assert.Equal(expected, options.TokenSigning.KeyDirectory);
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            if (Directory.Exists(configDir))
 | 
			
		||||
            {
 | 
			
		||||
                Directory.Delete(configDir, recursive: true);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public void Normalize_PreservesAbsoluteTokenSigningDirectory()
 | 
			
		||||
    {
 | 
			
		||||
        var absolute = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"), "keys");
 | 
			
		||||
        var options = new StandardPluginOptions
 | 
			
		||||
        {
 | 
			
		||||
            TokenSigning = { KeyDirectory = absolute }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        options.Normalize(Path.Combine(Path.GetTempPath(), "config", "standard.yaml"));
 | 
			
		||||
 | 
			
		||||
        Assert.Equal(Path.GetFullPath(absolute), options.TokenSigning.KeyDirectory);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,227 @@
 | 
			
		||||
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.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<string, string?>
 | 
			
		||||
            {
 | 
			
		||||
                ["passwordPolicy:minimumLength"] = "8",
 | 
			
		||||
                ["passwordPolicy:requireDigit"] = "false",
 | 
			
		||||
                ["passwordPolicy:requireSymbol"] = "false",
 | 
			
		||||
                ["lockout:enabled"] = "false",
 | 
			
		||||
                ["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<string, string?>(),
 | 
			
		||||
            "standard.yaml");
 | 
			
		||||
 | 
			
		||||
        var pluginContext = new AuthorityPluginContext(manifest, configuration);
 | 
			
		||||
        var services = new ServiceCollection();
 | 
			
		||||
        services.AddLogging();
 | 
			
		||||
        services.AddSingleton<IMongoDatabase>(database);
 | 
			
		||||
        services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
 | 
			
		||||
 | 
			
		||||
        var registrar = new StandardPluginRegistrar();
 | 
			
		||||
        registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
 | 
			
		||||
 | 
			
		||||
        var provider = services.BuildServiceProvider();
 | 
			
		||||
        var hostedServices = provider.GetServices<IHostedService>();
 | 
			
		||||
        foreach (var hosted in hostedServices)
 | 
			
		||||
        {
 | 
			
		||||
            if (hosted is StandardPluginBootstrapper bootstrapper)
 | 
			
		||||
            {
 | 
			
		||||
                await bootstrapper.StartAsync(CancellationToken.None);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var plugin = provider.GetRequiredService<IIdentityProviderPlugin>();
 | 
			
		||||
        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_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<string>(),
 | 
			
		||||
            new Dictionary<string, string?>(),
 | 
			
		||||
            "standard.yaml");
 | 
			
		||||
 | 
			
		||||
        var pluginContext = new AuthorityPluginContext(manifest, configuration);
 | 
			
		||||
        var services = new ServiceCollection();
 | 
			
		||||
        services.AddLogging();
 | 
			
		||||
        services.AddSingleton<IMongoDatabase>(database);
 | 
			
		||||
        services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
 | 
			
		||||
 | 
			
		||||
        var registrar = new StandardPluginRegistrar();
 | 
			
		||||
        registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
 | 
			
		||||
 | 
			
		||||
        using var provider = services.BuildServiceProvider();
 | 
			
		||||
        var plugin = provider.GetRequiredService<IIdentityProviderPlugin>();
 | 
			
		||||
 | 
			
		||||
        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<string, string?>
 | 
			
		||||
            {
 | 
			
		||||
                ["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<string, string?>(),
 | 
			
		||||
            "standard.yaml");
 | 
			
		||||
 | 
			
		||||
        var pluginContext = new AuthorityPluginContext(manifest, configuration);
 | 
			
		||||
        var services = new ServiceCollection();
 | 
			
		||||
        services.AddLogging();
 | 
			
		||||
        services.AddSingleton<IMongoDatabase>(database);
 | 
			
		||||
        services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
 | 
			
		||||
 | 
			
		||||
        var registrar = new StandardPluginRegistrar();
 | 
			
		||||
        registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
 | 
			
		||||
 | 
			
		||||
        using var provider = services.BuildServiceProvider();
 | 
			
		||||
        Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IIdentityProviderPlugin>());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [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<string, string?>
 | 
			
		||||
            {
 | 
			
		||||
                ["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<string, string?>(),
 | 
			
		||||
                configPath);
 | 
			
		||||
 | 
			
		||||
            var pluginContext = new AuthorityPluginContext(manifest, configuration);
 | 
			
		||||
            var services = new ServiceCollection();
 | 
			
		||||
            services.AddLogging();
 | 
			
		||||
            services.AddSingleton<IMongoDatabase>(database);
 | 
			
		||||
            services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
 | 
			
		||||
 | 
			
		||||
            var registrar = new StandardPluginRegistrar();
 | 
			
		||||
            registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
 | 
			
		||||
 | 
			
		||||
            using var provider = services.BuildServiceProvider();
 | 
			
		||||
            var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
 | 
			
		||||
            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 class InMemoryClientStore : IAuthorityClientStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
    public ValueTask<AuthorityClientDocument?> 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<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
 | 
			
		||||
        => ValueTask.FromResult(clients.Remove(clientId));
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,102 @@
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging.Abstractions;
 | 
			
		||||
using Mongo2Go;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Plugins.Abstractions;
 | 
			
		||||
using StellaOps.Authority.Plugin.Standard.Security;
 | 
			
		||||
using StellaOps.Authority.Plugin.Standard.Storage;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard.Tests;
 | 
			
		||||
 | 
			
		||||
public class StandardUserCredentialStoreTests : IAsyncLifetime
 | 
			
		||||
{
 | 
			
		||||
    private readonly MongoDbRunner runner;
 | 
			
		||||
    private readonly IMongoDatabase database;
 | 
			
		||||
    private readonly StandardPluginOptions options;
 | 
			
		||||
    private readonly StandardUserCredentialStore store;
 | 
			
		||||
 | 
			
		||||
    public StandardUserCredentialStoreTests()
 | 
			
		||||
    {
 | 
			
		||||
        runner = MongoDbRunner.Start(singleNodeReplSet: true);
 | 
			
		||||
        var client = new MongoClient(runner.ConnectionString);
 | 
			
		||||
        database = client.GetDatabase("authority-tests");
 | 
			
		||||
        options = new StandardPluginOptions
 | 
			
		||||
        {
 | 
			
		||||
            PasswordPolicy = new PasswordPolicyOptions
 | 
			
		||||
            {
 | 
			
		||||
                MinimumLength = 8,
 | 
			
		||||
                RequireDigit = true,
 | 
			
		||||
                RequireLowercase = true,
 | 
			
		||||
                RequireUppercase = true,
 | 
			
		||||
                RequireSymbol = false
 | 
			
		||||
            },
 | 
			
		||||
            Lockout = new LockoutOptions
 | 
			
		||||
            {
 | 
			
		||||
                Enabled = true,
 | 
			
		||||
                MaxAttempts = 2,
 | 
			
		||||
                WindowMinutes = 1
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
        store = new StandardUserCredentialStore(
 | 
			
		||||
            "standard",
 | 
			
		||||
            database,
 | 
			
		||||
            options,
 | 
			
		||||
            new Pbkdf2PasswordHasher(),
 | 
			
		||||
            NullLogger<StandardUserCredentialStore>.Instance);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public async Task VerifyPasswordAsync_ReturnsSuccess_ForValidCredentials()
 | 
			
		||||
    {
 | 
			
		||||
        var registration = new AuthorityUserRegistration(
 | 
			
		||||
            "alice",
 | 
			
		||||
            "Password1!",
 | 
			
		||||
            "Alice",
 | 
			
		||||
            null,
 | 
			
		||||
            false,
 | 
			
		||||
            new[] { "admin" },
 | 
			
		||||
            new Dictionary<string, string?>());
 | 
			
		||||
 | 
			
		||||
        var upsert = await store.UpsertUserAsync(registration, CancellationToken.None);
 | 
			
		||||
        Assert.True(upsert.Succeeded);
 | 
			
		||||
 | 
			
		||||
        var result = await store.VerifyPasswordAsync("alice", "Password1!", CancellationToken.None);
 | 
			
		||||
        Assert.True(result.Succeeded);
 | 
			
		||||
        Assert.Equal("alice", result.User?.Username);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Fact]
 | 
			
		||||
    public async Task VerifyPasswordAsync_EnforcesLockout_AfterRepeatedFailures()
 | 
			
		||||
    {
 | 
			
		||||
        await store.UpsertUserAsync(
 | 
			
		||||
            new AuthorityUserRegistration(
 | 
			
		||||
                "bob",
 | 
			
		||||
                "Password1!",
 | 
			
		||||
                "Bob",
 | 
			
		||||
                null,
 | 
			
		||||
                false,
 | 
			
		||||
                new[] { "operator" },
 | 
			
		||||
                new Dictionary<string, string?>()),
 | 
			
		||||
            CancellationToken.None);
 | 
			
		||||
 | 
			
		||||
        var first = await store.VerifyPasswordAsync("bob", "wrong", CancellationToken.None);
 | 
			
		||||
        Assert.False(first.Succeeded);
 | 
			
		||||
        Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, first.FailureCode);
 | 
			
		||||
 | 
			
		||||
        var second = await store.VerifyPasswordAsync("bob", "stillwrong", CancellationToken.None);
 | 
			
		||||
        Assert.False(second.Succeeded);
 | 
			
		||||
        Assert.Equal(AuthorityCredentialFailureCode.LockedOut, second.FailureCode);
 | 
			
		||||
        Assert.NotNull(second.RetryAfter);
 | 
			
		||||
        Assert.True(second.RetryAfter.Value > System.TimeSpan.Zero);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Task InitializeAsync() => Task.CompletedTask;
 | 
			
		||||
 | 
			
		||||
    public Task DisposeAsync()
 | 
			
		||||
    {
 | 
			
		||||
        runner.Dispose();
 | 
			
		||||
        return Task.CompletedTask;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,12 @@
 | 
			
		||||
<Project Sdk="Microsoft.NET.Sdk">
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <TargetFramework>net10.0</TargetFramework>
 | 
			
		||||
    <Nullable>enable</Nullable>
 | 
			
		||||
    <ImplicitUsings>enable</ImplicitUsings>
 | 
			
		||||
    <IsPackable>false</IsPackable>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
 | 
			
		||||
    <ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
		Reference in New Issue
	
	Block a user