diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/Login.razor b/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/Login.razor index 55f5d78..048d5ea 100644 --- a/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/Login.razor +++ b/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/Login.razor @@ -6,6 +6,7 @@ @inject NavigationManager NavigationManager @inject IJSRuntime JsRuntime +@inject IIdentityFeatureProvider Features @@ -28,18 +29,24 @@ ButtonStyle="ButtonStyle.Primary" form="login-form" /> - - Log in with a passkey - + @if (_flags.Passkey) + { + + Log in with a passkey + + } - { ["ReturnUrl"] = ReturnUrl }))" Text="Register as a new user" /> + @if (_flags.UserRegistration) + { + { ["ReturnUrl"] = ReturnUrl }))" Text="Register as a new user" /> + } @@ -47,6 +54,7 @@ private string? _error; private DotNetObjectReference? _dotNetRef; private IJSObjectReference? _jsModule; + private IdentityFeatureFlags _flags = new(); [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } @@ -56,9 +64,12 @@ ? $"/Account/PasskeyAssertion?returnUrl={Uri.EscapeDataString(ReturnUrl)}" : "/Account/PasskeyAssertion"; + protected override async Task OnInitializedAsync() => _flags = await Features.GetAsync(); + protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender) return; + // Don't start passkey conditional mediation when passkeys are disabled (#66). + if (!firstRender || !_flags.Passkey) return; _jsModule = await JsRuntime.InvokeAsync( "import", "./_content/AndreGoepel.Marten.Identity.Blazor/Components/Account/Shared/PasskeySubmit.razor.js"); diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Features/IIdentityFeatureProvider.cs b/src/AndreGoepel.Marten.Identity.Blazor/Features/IIdentityFeatureProvider.cs new file mode 100644 index 0000000..de0468e --- /dev/null +++ b/src/AndreGoepel.Marten.Identity.Blazor/Features/IIdentityFeatureProvider.cs @@ -0,0 +1,13 @@ +namespace AndreGoepel.Marten.Identity.Blazor; + +/// +/// Source of truth for identity feature availability (#66). The library ships an +/// options-backed default (); a host that +/// persists the flags (e.g. an admin-editable store) registers its own implementation +/// whose value takes precedence over configuration. The method is asynchronous because a +/// host implementation typically reads a database. +/// +public interface IIdentityFeatureProvider +{ + ValueTask GetAsync(CancellationToken cancellationToken = default); +} diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeature.cs b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeature.cs new file mode 100644 index 0000000..1cfed01 --- /dev/null +++ b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeature.cs @@ -0,0 +1,18 @@ +namespace AndreGoepel.Marten.Identity.Blazor; + +/// +/// Identity UI features whose availability a host can toggle at configuration or runtime +/// (#66). Disabling a feature hides its UI and makes its setup pages/endpoints unreachable. +/// +public enum IdentityFeature +{ + /// Self-service account registration and its confirmation/resend pages. + UserRegistration, + + /// Two-factor authenticator setup and management (the login-time challenge for + /// users who already enrolled is intentionally kept reachable). + TwoFactor, + + /// WebAuthn passkey management and login/creation endpoints. + Passkey, +} diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureFlags.cs b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureFlags.cs new file mode 100644 index 0000000..27df644 --- /dev/null +++ b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureFlags.cs @@ -0,0 +1,21 @@ +namespace AndreGoepel.Marten.Identity.Blazor; + +/// +/// A snapshot of which identity features are enabled (#66). Every flag defaults to +/// true, so a host that configures nothing keeps the full feature set. +/// +public sealed record IdentityFeatureFlags +{ + public bool UserRegistration { get; init; } = true; + public bool TwoFactor { get; init; } = true; + public bool Passkey { get; init; } = true; + + public bool IsEnabled(IdentityFeature feature) => + feature switch + { + IdentityFeature.UserRegistration => UserRegistration, + IdentityFeature.TwoFactor => TwoFactor, + IdentityFeature.Passkey => Passkey, + _ => true, + }; +} diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureGateApplicationBuilderExtensions.cs b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureGateApplicationBuilderExtensions.cs new file mode 100644 index 0000000..d28f74c --- /dev/null +++ b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureGateApplicationBuilderExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Builder; + +namespace AndreGoepel.Marten.Identity.Blazor; + +public static class IdentityFeatureGateApplicationBuilderExtensions +{ + /// + /// Adds the middleware that makes disabled identity features (registration, 2FA setup, + /// passkeys) unreachable by direct URL (#66). Wire it after authentication/authorization + /// and before MapRazorComponents / MapAdditionalIdentityEndpoints so it can + /// intercept both the Razor pages and the passkey endpoints. + /// + public static IApplicationBuilder UseMartenIdentityFeatureGate(this IApplicationBuilder app) => + app.UseMiddleware(); +} diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureGateMiddleware.cs b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureGateMiddleware.cs new file mode 100644 index 0000000..1d5724b --- /dev/null +++ b/src/AndreGoepel.Marten.Identity.Blazor/Features/IdentityFeatureGateMiddleware.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Http; + +namespace AndreGoepel.Marten.Identity.Blazor; + +/// +/// Blocks the pages and endpoints of a disabled identity feature so it is unreachable by +/// direct URL, not merely hidden in the UI (#66). Modelled on +/// SetupRedirectMiddleware: a browser page navigation to a disabled feature is +/// redirected to the login page; any other request (a fetch, a passkey/attestation +/// endpoint call) gets a 404. The login-time two-factor challenge is intentionally left +/// reachable so users who already enrolled can still complete sign-in. +/// +public sealed class IdentityFeatureGateMiddleware(RequestDelegate next) +{ + public async Task Invoke(HttpContext context, IIdentityFeatureProvider features) + { + var path = context.Request.Path.Value ?? ""; + var feature = MapPathToFeature(path); + + if (feature is { } gated) + { + var flags = await features.GetAsync(context.RequestAborted); + if (!flags.IsEnabled(gated)) + { + if (IsPageNavigation(context)) + context.Response.Redirect("/Account/Login"); + else + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + } + + await next.Invoke(context); + } + + /// + /// Maps a request path to the feature that gates it, or null when the path is not + /// feature-gated. The two-factor login challenge (/Account/LoginWith2fa, + /// /Account/LoginWithRecoveryCode) is deliberately absent so disabling 2FA cannot + /// lock out users who already enrolled — only setup/management is gated. + /// + private static IdentityFeature? MapPathToFeature(string path) + { + if ( + PathIs(path, "/Account/Register") + || PathIs(path, "/Account/RegisterConfirmation") + || PathIs(path, "/Account/ResendEmailConfirmation") + ) + return IdentityFeature.UserRegistration; + + if ( + PathIs(path, "/Account/Manage/TwoFactorAuthentication") + || PathIs(path, "/Account/Manage/EnableAuthenticator") + || PathIs(path, "/Account/Manage/Disable2fa") + || PathIs(path, "/Account/Manage/GenerateRecoveryCodes") + || PathStartsWith(path, "/Account/Manage/ResetAuthenticator") + ) + return IdentityFeature.TwoFactor; + + if ( + PathStartsWith(path, "/Account/Manage/Passkeys") + || PathIs(path, "/Account/Manage/PasskeyAttestation") + || PathIs(path, "/Account/PasskeyCreationOptions") + || PathIs(path, "/Account/PasskeyRequestOptions") + || PathIs(path, "/Account/PasskeyAssertion") + ) + return IdentityFeature.Passkey; + + return null; + } + + private static bool PathIs(string path, string target) => + path.Equals(target, StringComparison.OrdinalIgnoreCase); + + private static bool PathStartsWith(string path, string prefix) => + path.Equals(prefix, StringComparison.OrdinalIgnoreCase) + || path.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase); + + /// + /// True only for browser page navigations, so sub-resource fetches and endpoint calls + /// get a 404 rather than an HTML redirect. Mirrors the heuristic in + /// SetupRedirectMiddleware. + /// + private static bool IsPageNavigation(HttpContext context) + { + var dest = context.Request.Headers["Sec-Fetch-Dest"].FirstOrDefault(); + if (!string.IsNullOrEmpty(dest)) + return dest is "document" or "iframe" or "embed" or "object"; + + return context.Request.Headers.Accept.Any(v => + v?.Contains("text/html", StringComparison.OrdinalIgnoreCase) == true + ); + } +} diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Features/OptionsIdentityFeatureProvider.cs b/src/AndreGoepel.Marten.Identity.Blazor/Features/OptionsIdentityFeatureProvider.cs new file mode 100644 index 0000000..e69aa17 --- /dev/null +++ b/src/AndreGoepel.Marten.Identity.Blazor/Features/OptionsIdentityFeatureProvider.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Options; + +namespace AndreGoepel.Marten.Identity.Blazor; + +/// +/// Default that reads the flags from +/// (the configuration baseline). Replace it with +/// a host implementation to source the flags from a runtime store where the persisted value +/// takes precedence over configuration. +/// +internal sealed class OptionsIdentityFeatureProvider( + IOptionsMonitor options +) : IIdentityFeatureProvider +{ + public ValueTask GetAsync(CancellationToken cancellationToken = default) + { + var current = options.CurrentValue; + return ValueTask.FromResult( + new IdentityFeatureFlags + { + UserRegistration = current.EnableUserRegistration, + TwoFactor = current.EnableTwoFactor, + Passkey = current.EnablePasskey, + } + ); + } +} diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Initialization.cs b/src/AndreGoepel.Marten.Identity.Blazor/Initialization.cs index 070d06b..c689ef2 100644 --- a/src/AndreGoepel.Marten.Identity.Blazor/Initialization.cs +++ b/src/AndreGoepel.Marten.Identity.Blazor/Initialization.cs @@ -1,6 +1,7 @@ using AndreGoepel.Marten.Identity.Blazor.Components.Account; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace AndreGoepel.Marten.Identity.Blazor; @@ -23,6 +24,10 @@ public static IServiceCollection AddMartenIdentityBlazor( options.Configure(configureOptions); } + // Default feature-flag provider reads the options baseline (#66). TryAdd lets a host + // register a persistence-backed provider that takes precedence. + services.TryAddScoped(); + return services; } } diff --git a/src/AndreGoepel.Marten.Identity.Blazor/MartenIdentityBlazorOptions.cs b/src/AndreGoepel.Marten.Identity.Blazor/MartenIdentityBlazorOptions.cs index 59f995a..34b25a7 100644 --- a/src/AndreGoepel.Marten.Identity.Blazor/MartenIdentityBlazorOptions.cs +++ b/src/AndreGoepel.Marten.Identity.Blazor/MartenIdentityBlazorOptions.cs @@ -8,4 +8,18 @@ public sealed class MartenIdentityBlazorOptions /// without a suffix — the library ships no default branding. /// public string? ApplicationName { get; set; } + + /// + /// Configuration baseline for the identity feature flags (#66). These are the values the + /// default serves; a host that persists the flags + /// registers its own provider whose stored value takes precedence. All default to + /// true, so the full feature set is available unless a host opts out. + /// + public bool EnableUserRegistration { get; set; } = true; + + /// + public bool EnableTwoFactor { get; set; } = true; + + /// + public bool EnablePasskey { get; set; } = true; } diff --git a/src/AndreGoepel.Marten.Identity.Blazor/README.md b/src/AndreGoepel.Marten.Identity.Blazor/README.md index bfac252..0d5a680 100644 --- a/src/AndreGoepel.Marten.Identity.Blazor/README.md +++ b/src/AndreGoepel.Marten.Identity.Blazor/README.md @@ -41,6 +41,63 @@ app.MapRazorComponents() This makes the routable pages in the RCL (e.g. `/Account/Login`) discoverable by the Blazor router. +## Feature flags (registration, 2FA, passkeys) + +The registration, two-factor and passkey features can be turned off. Disabling a feature +hides its UI **and** makes its setup pages/endpoints unreachable by direct URL — the +login-time 2FA/recovery challenge stays reachable so users who already enrolled can still +sign in, and passkey login falls back to password. + +**1. Baseline from configuration** (all default `true`): + +```csharp +builder.Services.AddMartenIdentityBlazor(options => +{ + options.EnableUserRegistration = true; + options.EnableTwoFactor = true; + options.EnablePasskey = false; // e.g. passkeys off +}); +``` + +**2. Enforce the gate** — add the middleware after authentication/authorization and before +`MapRazorComponents` / `MapAdditionalIdentityEndpoints` so it can block both pages and the +passkey endpoints: + +```csharp +app.UseAuthentication(); +app.UseAuthorization(); +app.UseMartenIdentityFeatureGate(); // blocks disabled-feature pages/endpoints +app.MapAdditionalIdentityEndpoints(); +app.MapRazorComponents() /* … */; +``` + +**3. Runtime override (optional).** To source the flags from a store (e.g. an admin +settings page) where the persisted value beats configuration, register your own +`IIdentityFeatureProvider` — it takes precedence over the built-in options-backed default: + +```csharp +builder.Services.AddScoped(); +``` + +```csharp +public sealed class MyDbFeatureProvider(IMyStore store) : IIdentityFeatureProvider +{ + public async ValueTask GetAsync(CancellationToken ct = default) + { + var saved = await store.GetIdentityFlagsAsync(ct); // DB value takes precedence + return new IdentityFeatureFlags + { + UserRegistration = saved.Registration, + TwoFactor = saved.TwoFactor, + Passkey = saved.Passkey, + }; + } +} +``` + +Your own account-navigation links should call the same provider to hide entry points to +disabled features. + ## What's included | Area | Components / Pages | diff --git a/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Account/Pages/Login.Tests.cs b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Account/Pages/Login.Tests.cs new file mode 100644 index 0000000..9ba0d77 --- /dev/null +++ b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Account/Pages/Login.Tests.cs @@ -0,0 +1,92 @@ +using AndreGoepel.Marten.Identity.Blazor; +using AndreGoepel.Marten.Identity.Blazor.Components.Account.Pages; +using AndreGoepel.Marten.Identity.Http; +using AndreGoepel.Marten.Identity.Users; +using Bunit; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using Radzen; + +namespace AndreGoepel.Marten.Identity.Blazor.Tests.Account.Pages; + +public class LoginTests : BunitContext +{ + private const string RegisterLink = "Register as a new user"; + private const string PasskeyButton = "Log in with a passkey"; + + [Fact] + public void AllEnabled_ShowsRegisterLinkAndPasskeyButton() + { + var cut = Render(new IdentityFeatureFlags()); + + Assert.Contains(RegisterLink, cut.Markup); + Assert.Contains(PasskeyButton, cut.Markup); + } + + [Fact] + public void RegistrationDisabled_HidesRegisterLink() + { + var cut = Render(new IdentityFeatureFlags { UserRegistration = false }); + + Assert.DoesNotContain(RegisterLink, cut.Markup); + Assert.Contains(PasskeyButton, cut.Markup); // unrelated feature untouched + } + + [Fact] + public void PasskeyDisabled_HidesPasskeyButton() + { + var cut = Render(new IdentityFeatureFlags { Passkey = false }); + + Assert.DoesNotContain(PasskeyButton, cut.Markup); + Assert.Contains(RegisterLink, cut.Markup); + } + + private IRenderedComponent Render(IdentityFeatureFlags flags) + { + JSInterop.Mode = JSRuntimeMode.Loose; + + var um = Substitute.For>( + Substitute.For>(), + null, + null, + null, + null, + null, + null, + null, + null + ); + var sm = Substitute.For>( + um, + Substitute.For(), + Substitute.For>(), + Options.Create(new IdentityOptions()), + Substitute.For>>(), + Substitute.For(), + Substitute.For>() + ); + + Services.AddSingleton(um); + Services.AddSingleton(sm); + Services.AddSingleton>(new PasswordHasher()); + Services.AddSingleton(Substitute.For>()); + Services.AddSingleton(new NotificationService()); + Services.AddSingleton(new LoginTokenProtector(DataProtectionProvider.Create("Tests"))); + Services.AddSingleton(new StubProvider(flags)); + + return Render(); + } + + private sealed class StubProvider(IdentityFeatureFlags flags) : IIdentityFeatureProvider + { + public ValueTask GetAsync( + CancellationToken cancellationToken = default + ) => ValueTask.FromResult(flags); + } +} diff --git a/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/IdentityFeatureGateMiddlewareTests.cs b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/IdentityFeatureGateMiddlewareTests.cs new file mode 100644 index 0000000..57e1194 --- /dev/null +++ b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/IdentityFeatureGateMiddlewareTests.cs @@ -0,0 +1,150 @@ +using AndreGoepel.Marten.Identity.Blazor; +using Microsoft.AspNetCore.Http; + +namespace AndreGoepel.Marten.Identity.Blazor.Tests.Features; + +public class IdentityFeatureGateMiddlewareTests +{ + [Theory] + [InlineData("/Account/Register")] + [InlineData("/Account/RegisterConfirmation")] + [InlineData("/Account/ResendEmailConfirmation")] + public async Task Registration_Disabled_PageNavigation_RedirectsToLogin(string path) + { + var (mw, ctx, called) = Build(path, secFetchDest: "document"); + + await mw.Invoke(ctx, Provider(new() { UserRegistration = false })); + + Assert.Equal("/Account/Login", ctx.Response.Headers.Location.ToString()); + Assert.False(called.Value); + } + + [Fact] + public async Task Registration_Disabled_Fetch_Returns404() + { + var (mw, ctx, called) = Build("/Account/Register", secFetchDest: "empty"); + + await mw.Invoke(ctx, Provider(new() { UserRegistration = false })); + + Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode); + Assert.False(called.Value); + } + + [Fact] + public async Task Registration_Enabled_PassesThrough() + { + var (mw, ctx, called) = Build("/Account/Register", secFetchDest: "document"); + + await mw.Invoke(ctx, Provider(new() { UserRegistration = true })); + + Assert.True(called.Value); + } + + [Theory] + [InlineData("/Account/Manage/TwoFactorAuthentication")] + [InlineData("/Account/Manage/EnableAuthenticator")] + [InlineData("/Account/Manage/Disable2fa")] + [InlineData("/Account/Manage/GenerateRecoveryCodes")] + [InlineData("/Account/Manage/ResetAuthenticator")] + [InlineData("/Account/Manage/ResetAuthenticator/ConfirmReset")] + public async Task TwoFactorSetup_Disabled_IsBlocked(string path) + { + var (mw, ctx, called) = Build(path, secFetchDest: "empty"); + + await mw.Invoke(ctx, Provider(new() { TwoFactor = false })); + + Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode); + Assert.False(called.Value); + } + + [Theory] + [InlineData("/Account/LoginWith2fa")] + [InlineData("/Account/LoginWithRecoveryCode")] + public async Task TwoFactorLoginChallenge_NotGated_EvenWhenDisabled(string path) + { + // Graceful gating (#66): a user who already enrolled must still be able to complete + // the login-time 2FA challenge even after the feature is turned off. + var (mw, ctx, called) = Build(path, secFetchDest: "document"); + + await mw.Invoke(ctx, Provider(new() { TwoFactor = false })); + + Assert.True(called.Value); + } + + [Theory] + [InlineData("/Account/Manage/Passkeys")] + [InlineData("/Account/Manage/Passkeys/Create")] + [InlineData("/Account/Manage/Passkeys/Rename/abc")] + [InlineData("/Account/Manage/PasskeyAttestation")] + [InlineData("/Account/PasskeyCreationOptions")] + [InlineData("/Account/PasskeyRequestOptions")] + [InlineData("/Account/PasskeyAssertion")] + public async Task Passkey_Disabled_IsBlocked(string path) + { + var (mw, ctx, called) = Build(path, secFetchDest: "empty"); + + await mw.Invoke(ctx, Provider(new() { Passkey = false })); + + Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode); + Assert.False(called.Value); + } + + [Theory] + [InlineData("/Account/Login")] + [InlineData("/Account/ForgotPassword")] + [InlineData("/Account/Manage/Profile")] + [InlineData("/dashboard")] + public async Task UngatedPaths_AlwaysPassThrough(string path) + { + var (mw, ctx, called) = Build(path, secFetchDest: "document"); + + // Everything off, but these paths are not feature-gated. + await mw.Invoke( + ctx, + Provider( + new() + { + UserRegistration = false, + TwoFactor = false, + Passkey = false, + } + ) + ); + + Assert.True(called.Value); + } + + private static ( + IdentityFeatureGateMiddleware Middleware, + DefaultHttpContext Context, + Box Called + ) Build(string path, string? secFetchDest) + { + var called = new Box(); + var middleware = new IdentityFeatureGateMiddleware(_ => + { + called.Value = true; + return Task.CompletedTask; + }); + var context = new DefaultHttpContext(); + context.Request.Path = path; + if (secFetchDest is not null) + context.Request.Headers["Sec-Fetch-Dest"] = secFetchDest; + return (middleware, context, called); + } + + private static IIdentityFeatureProvider Provider(IdentityFeatureFlags flags) => + new StubProvider(flags); + + private sealed class StubProvider(IdentityFeatureFlags flags) : IIdentityFeatureProvider + { + public ValueTask GetAsync( + CancellationToken cancellationToken = default + ) => ValueTask.FromResult(flags); + } + + private sealed class Box + { + public T Value { get; set; } = default!; + } +} diff --git a/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/OptionsIdentityFeatureProviderTests.cs b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/OptionsIdentityFeatureProviderTests.cs new file mode 100644 index 0000000..7df0736 --- /dev/null +++ b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/OptionsIdentityFeatureProviderTests.cs @@ -0,0 +1,48 @@ +using AndreGoepel.Marten.Identity.Blazor; +using Microsoft.Extensions.DependencyInjection; + +namespace AndreGoepel.Marten.Identity.Blazor.Tests.Features; + +public class OptionsIdentityFeatureProviderTests +{ + [Fact] + public async Task Default_AllFeaturesEnabled() + { + var flags = await ResolveAsync(configure: null); + + Assert.True(flags.UserRegistration); + Assert.True(flags.TwoFactor); + Assert.True(flags.Passkey); + } + + [Fact] + public async Task ReflectsConfiguredOptionValues() + { + var flags = await ResolveAsync(o => + { + o.EnableUserRegistration = false; + o.EnableTwoFactor = false; + o.EnablePasskey = true; + }); + + Assert.False(flags.UserRegistration); + Assert.False(flags.TwoFactor); + Assert.True(flags.Passkey); + } + + // Resolves the default provider through the real DI wiring (AddMartenIdentityBlazor), + // proving both the registration and the options-to-flags mapping. + private static async Task ResolveAsync( + Action? configure + ) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMartenIdentityBlazor(configure); + + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var featureProvider = scope.ServiceProvider.GetRequiredService(); + return await featureProvider.GetAsync(); + } +}