// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable enable

using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using System.Text.RegularExpressions;
using Identity.DefaultUI.WebSite;
using Identity.DefaultUI.WebSite.Data;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.AspNetCore.Testing;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
using Xunit.Sdk;

namespace Microsoft.AspNetCore.Identity.FunctionalTests;

public class MapIdentityApiTests : LoggedTest
{
    private static string Email { get; } = $"{Guid.NewGuid()}@example.com";
    private static string Password { get; } = "[PLACEHOLDER]-1a";
    private static Uri BaseAddress { get; } = new Uri("http://example.com");

    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task CanRegisterUser(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();

        AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Email, Password }));
    }

    [Fact]
    public async Task RegisterFailsGivenNoEmail()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();

        AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Password }));
    }

    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task RegisterFailsGivenInvalidEmail(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();

        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/register", new { Email = "invalid", Password }),
            "InvalidEmail");
    }

    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task RegisterFailsGivenDuplicateEmail(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();

        AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Email, Password }));
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/register", new { Email, Password }),
            "DuplicateUserName");
    }

    [Fact]
    public async Task LoginFailsGivenUnregisteredUser()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();

        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "Failed");
    }

    [Fact]
    public async Task LoginFailsGivenWrongPassword()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password = "wrong" }),
            "Failed");
    }

    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task CanLoginWithBearerToken(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });

        loginResponse.EnsureSuccessStatusCode();
        Assert.False(loginResponse.Headers.Contains(HeaderNames.SetCookie));

        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var tokenType = loginContent.GetProperty("tokenType").GetString();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        var expiresIn = loginContent.GetProperty("expiresIn").GetDouble();
        Assert.True(loginContent.TryGetProperty("refreshToken", out _));

        Assert.Equal("Bearer", tokenType);
        Assert.Equal(3600, expiresIn);

        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
    }

    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task CanChangeAccessTokenResponseJsonOptions(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityActions[addIdentityMode](services);
            services.ConfigureHttpJsonOptions(options =>
            {
                options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
            });
        });
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });

        loginResponse.EnsureSuccessStatusCode();

        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var tokenType = loginContent.GetProperty("token_type").GetString();
        var accessToken = loginContent.GetProperty("access_token").GetString();
        var expiresIn = loginContent.GetProperty("expires_in").GetDouble();
        Assert.True(loginContent.TryGetProperty("refresh_token", out _));

        Assert.Equal("Bearer", tokenType);
        Assert.Equal(3600, expiresIn);

        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
    }

    [Fact]
    public async Task CanCustomizeBearerTokenExpiration()
    {
        var clock = new MockTimeProvider();
        var expireTimeSpan = TimeSpan.FromSeconds(42);

        await using var app = await CreateAppAsync(services =>
        {
            services.AddSingleton<TimeProvider>(clock);
            services.AddDbContext<ApplicationDbContext>((sp, options) => options.UseSqlite(sp.GetRequiredService<SqliteConnection>()));
            services.AddIdentityCore<ApplicationUser>().AddApiEndpoints().AddEntityFrameworkStores<ApplicationDbContext>();
            services.AddAuthentication().AddBearerToken(IdentityConstants.BearerScheme, options =>
            {
                options.BearerTokenExpiration = expireTimeSpan;
            });
        });

        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });

        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        var expiresIn = loginContent.GetProperty("expiresIn").GetDouble();

        Assert.Equal(expireTimeSpan.TotalSeconds, expiresIn);

        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);

        // Works without time passing.
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));

        clock.Advance(TimeSpan.FromSeconds(expireTimeSpan.TotalSeconds - 1));

        // Still works one second before expiration.
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));

        clock.Advance(TimeSpan.FromSeconds(1));

        // Fails the second the BearerTokenExpiration elapses.
        AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello"));
    }

    [Fact]
    public async Task CanLoginWithCookies()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email, Password });

        AssertOkAndEmpty(loginResponse);
        Assert.True(loginResponse.Headers.TryGetValues(HeaderNames.SetCookie, out var setCookieHeaders));
        var setCookieHeader = Assert.Single(setCookieHeaders);

        // The compiler does not see Assert.True's DoesNotReturnIfAttribute :(
        if (setCookieHeader.Split(';', 2) is not [var cookie, _])
        {
            throw new XunitException("Invalid Set-Cookie header!");
        }

        client.DefaultRequestHeaders.Add(HeaderNames.Cookie, cookie);
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
    }

    [Fact]
    public async Task CannotLoginWithCookiesWithOnlyCoreServices()
    {
        await using var app = await CreateAppAsync(services => AddIdentityApiEndpointsBearerOnly(services));
        using var client = app.GetTestClient();

        await RegisterAsync(client);

        await Assert.ThrowsAsync<InvalidOperationException>(()
            => client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email, Password }));
    }

    [Fact]
    public async Task CanReadBearerTokenFromQueryString()
    {
        await using var app = await CreateAppAsync(services =>
        {
            services.AddDbContext<ApplicationDbContext>((sp, options) => options.UseSqlite(sp.GetRequiredService<SqliteConnection>()));
            services.AddIdentityCore<ApplicationUser>().AddApiEndpoints().AddEntityFrameworkStores<ApplicationDbContext>();
            services.AddAuthentication().AddBearerToken(IdentityConstants.BearerScheme, options =>
            {
                options.Events.OnMessageReceived = context =>
                {
                    context.Token = (string?)context.Request.Query["accessToken"];
                    return Task.CompletedTask;
                };
            });
        });

        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });

        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();

        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync($"/auth/hello?accessToken={accessToken}"));

        // The normal header still works
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
    }

    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task Returns401UnauthorizedStatusGivenNoBearerTokenOrCookie(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();

        AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello"));

        client.DefaultRequestHeaders.Authorization = new("Bearer");
        AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello"));

        client.DefaultRequestHeaders.Authorization = new("Bearer", "");
        AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello"));
    }

    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task CanUseRefreshToken(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });
        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var refreshToken = loginContent.GetProperty("refreshToken").GetString();

        var refreshResponse = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken });
        var refreshContent = await refreshResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = refreshContent.GetProperty("accessToken").GetString();

        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
    }

    [Fact]
    public async Task Returns401UnauthorizedStatusGivenNullOrEmptyRefreshToken()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();

        string? refreshToken = null;
        AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }));

        refreshToken = "";
        AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }));
    }

    [Fact]
    public async Task CanCustomizeRefreshTokenExpiration()
    {
        var clock = new MockTimeProvider();
        var expireTimeSpan = TimeSpan.FromHours(42);

        await using var app = await CreateAppAsync(services =>
        {
            services.AddSingleton<TimeProvider>(clock);
            services.AddDbContext<ApplicationDbContext>((sp, options) => options.UseSqlite(sp.GetRequiredService<SqliteConnection>()));
            services.AddIdentityCore<ApplicationUser>().AddApiEndpoints().AddEntityFrameworkStores<ApplicationDbContext>();
            services.AddAuthentication().AddBearerToken(IdentityConstants.BearerScheme, options =>
            {
                options.RefreshTokenExpiration = expireTimeSpan;
            });
        });

        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });

        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var refreshToken = loginContent.GetProperty("refreshToken").GetString();
        var accessToken = loginContent.GetProperty("refreshToken").GetString();

        // Works without time passing.
        var refreshResponse = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken });
        Assert.True(refreshResponse.IsSuccessStatusCode);

        clock.Advance(TimeSpan.FromSeconds(expireTimeSpan.TotalSeconds - 1));

        // Still works one second before expiration.
        refreshResponse = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken });
        Assert.True(refreshResponse.IsSuccessStatusCode);

        // The bearer token stopped working 41 hours ago with the default 1 hour expiration.
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello"));

        clock.Advance(TimeSpan.FromSeconds(1));

        // Fails the second the RefreshTokenExpiration elapses.
        AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }));

        // But the last refreshToken from the successful /refresh only a second ago has not expired.
        var refreshContent = await refreshResponse.Content.ReadFromJsonAsync<JsonElement>();
        refreshToken = refreshContent.GetProperty("refreshToken").GetString();

        refreshResponse = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken });
        refreshContent = await refreshResponse.Content.ReadFromJsonAsync<JsonElement>();
        accessToken = refreshContent.GetProperty("accessToken").GetString();

        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));
    }

    [Fact]
    public async Task RefreshReturns401UnauthorizedIfSecurityStampChanges()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var refreshToken = await LoginAsync(client);

        var userManager = app.Services.GetRequiredService<UserManager<ApplicationUser>>();
        var user = await userManager.FindByNameAsync(Email);

        Assert.NotNull(user);

        await userManager.UpdateSecurityStampAsync(user);

        AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }));
    }

    [Fact]
    public async Task RefreshUpdatesUserFromStore()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var refreshToken = await LoginAsync(client);

        var userManager = app.Services.GetRequiredService<UserManager<ApplicationUser>>();
        var user = await userManager.FindByNameAsync(Email);

        Assert.NotNull(user);

        var newUsername = $"{Guid.NewGuid()}@example.com";
        user.UserName = newUsername;
        await userManager.UpdateAsync(user);

        var refreshResponse = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken });
        var refreshContent = await refreshResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = refreshContent.GetProperty("accessToken").GetString();

        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
        Assert.Equal($"Hello, {newUsername}!", await client.GetStringAsync("/auth/hello"));
    }

    [Fact]
    public async Task LoginCanBeLockedOut()
    {
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.Configure<IdentityOptions>(options =>
            {
                options.Lockout.MaxFailedAccessAttempts = 2;
            });
        });
        using var client = app.GetTestClient();

        await RegisterAsync(client);

        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password = "wrong" }),
            "Failed");

        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password = "wrong" }),
            "LockedOut");

        Assert.Single(TestSink.Writes, w =>
            w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" &&
            w.EventId == new EventId(3, "UserLockedOut"));

        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "LockedOut");
    }

    [Fact]
    public async Task LockoutCanBeDisabled()
    {
        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.Configure<IdentityOptions>(options =>
            {
                options.Lockout.AllowedForNewUsers = false;
                options.Lockout.MaxFailedAccessAttempts = 1;
            });
        });
        using var client = app.GetTestClient();

        await RegisterAsync(client);

        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password = "wrong" }),
            "Failed");

        Assert.DoesNotContain(TestSink.Writes, w =>
            w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" &&
            w.EventId == new EventId(3, "UserLockedOut"));

        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password }));
    }

    [Fact]
    public async Task AccountConfirmationCanBeEnabled()
    {
        var emailSender = new TestEmailSender();

        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedAccount = true;
            });
        });
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        await LoginWithEmailConfirmationAsync(client, emailSender);

        Assert.Single(emailSender.Emails);
        Assert.Single(TestSink.Writes, w =>
            w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" &&
            w.EventId == new EventId(4, "UserCannotSignInWithoutConfirmedAccount"));
    }

    [Fact]
    public async Task EmailConfirmationCanBeEnabled()
    {
        var emailSender = new TestEmailSender();

        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedEmail = true;
            });
        });
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        await LoginWithEmailConfirmationAsync(client, emailSender);

        Assert.Single(emailSender.Emails);
        Assert.Single(TestSink.Writes, w =>
            w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" &&
            w.EventId == new EventId(0, "UserCannotSignInWithoutConfirmedEmail"));
    }

    [Fact]
    public async Task EmailConfirmationCanBeResent()
    {
        var emailSender = new TestEmailSender();

        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedEmail = true;
            });
        });
        using var client = app.GetTestClient();

        await RegisterAsync(client);

        var firstEmail = Assert.Single(emailSender.Emails);
        Assert.Equal("Confirm your email", firstEmail.Subject);
        Assert.Equal(Email, firstEmail.Address);

        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "NotAllowed");

        AssertOk(await client.PostAsJsonAsync("/identity/resendConfirmationEmail", new { Email = "wrong" }));
        AssertOk(await client.PostAsJsonAsync("/identity/resendConfirmationEmail", new { Email = Email }));

        // Even though both resendConfirmationEmail requests returned a 200, only one for a valid registration was sent
        Assert.Equal(2, emailSender.Emails.Count);
        var resentEmail = emailSender.Emails[1];
        Assert.Equal("Confirm your email", resentEmail.Subject);
        Assert.Equal(Email, resentEmail.Address);

        AssertOk(await client.GetAsync(GetEmailConfirmationLink(resentEmail)));
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password }));
    }

    [Fact]
    public async Task AccountConfirmationEmailCanBeCustomized()
    {
        var emailSender = new TestEmailSender();
        var customEmailSender = new TestCustomEmailSender(emailSender);

        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender<ApplicationUser>>(customEmailSender);
        });
        using var client = app.GetTestClient();

        await RegisterAsync(client);

        var email = Assert.Single(emailSender.Emails);
        Assert.Equal(Email, email.Address);
        Assert.Equal(TestCustomEmailSender.CustomSubject, email.Subject);
        Assert.Equal(TestCustomEmailSender.CustomMessage, email.HtmlMessage);
    }

    [Fact]
    public async Task CanAddEndpointsToMultipleRouteGroupsForSameUserType()
    {
        // Test with confirmation email since that tests link generation capabilities
        var emailSender = new TestEmailSender();

        await using var app = await CreateAppAsync<ApplicationUser, ApplicationDbContext>(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedAccount = true;
            });
        }, autoStart: false);

        app.MapGroup("/identity2").MapIdentityApi<ApplicationUser>();

        await app.StartAsync();
        using var client = app.GetTestClient();

        // We have to use different user names to register twice since they use the same store.
        await RegisterAsync(client, "/identity", "a@example.com");
        await LoginWithEmailConfirmationAsync(client, emailSender, "/identity", "a@example.com");

        await RegisterAsync(client, "/identity2", "b@example.com");
        await LoginWithEmailConfirmationAsync(client, emailSender, "/identity2", "b@example.com");
    }

    [Fact]
    public async Task CanAddEndpointsToMultipleRouteGroupsForMultipleUsersTypes()
    {
        // Test with confirmation email since that tests link generation capabilities
        var emailSender = new TestEmailSender();

        // Even with OnModelCreating tricks to prefix table names, using the same database
        // for multiple user tables is difficult because index conflics, so we just use a different db.
        var dbConnection2 = new SqliteConnection("DataSource=:memory:");

        await using var app = await CreateAppAsync<ApplicationUser, ApplicationDbContext>(services =>
        {
            AddIdentityApiEndpoints<ApplicationUser, ApplicationDbContext>(services);

            // We just added cookie and/or bearer auth scheme(s) above. We cannot re-add these without an error.
            services
                .AddDbContext<IdentityDbContext>((sp, options) => options.UseSqlite(dbConnection2))
                .AddIdentityCore<IdentityUser>()
                .AddEntityFrameworkStores<IdentityDbContext>()
                .AddApiEndpoints();

            services.AddSingleton<IDisposable>(_ => dbConnection2);

            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedAccount = true;
            });
        }, autoStart: false);

        // The following two lines are already taken care of by CreateAppAsync for ApplicationUser and ApplicationDbContext
        await dbConnection2.OpenAsync();
        await app.Services.GetRequiredService<IdentityDbContext>().Database.EnsureCreatedAsync();

        app.MapGroup("/identity2").MapIdentityApi<IdentityUser>();

        await app.StartAsync();
        using var client = app.GetTestClient();

        // We can use the same email twice since we're using two distinct DbContexts.
        await RegisterAsync(client, "/identity");
        await LoginWithEmailConfirmationAsync(client, emailSender, "/identity");

        await RegisterAsync(client, "/identity2");
        await LoginWithEmailConfirmationAsync(client, emailSender, "/identity2");
    }

    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task CanEnableAndLoginWithTwoFactor(string addIdentityMode)
    {
        await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]);
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });

        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        var refreshToken = loginContent.GetProperty("refreshToken").GetString();

        AssertUnauthorizedAndEmpty(await client.PostAsync("/identity/manage/2fa", null));

        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);

        // We cannot enable 2fa without verifying we can produce a valid token.
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/2fa", new { Enable = true }),
            "RequiresTwoFactor");
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/2fa", new { Enable = true, TwoFactorCode = "wrong" }),
            "InvalidTwoFactorCode");

        // Even though we're now authenticated, we must send at least "{}" in the request body. An empty request fails.
        AssertBadRequestAndEmpty(await client.PostAsync("/identity/manage/2fa", null));
        AssertBadRequestAndEmpty(await client.PostAsJsonAsync<object?>("/identity/manage/2fa", null));

        var twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.False(twoFactorKeyContent.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.False(twoFactorKeyContent.GetProperty("isMachineRemembered").GetBoolean());

        var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString();

        var keyBytes = Base32.FromBase32(sharedKey);
        var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(CultureInfo.InvariantCulture);

        var enable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true });
        var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.False(enable2faContent.GetProperty("isMachineRemembered").GetBoolean());

        // We can still access auth'd endpoints with old access token.
        Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello"));

        // But the refresh token is invalidated by the security stamp.
        AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }));

        client.DefaultRequestHeaders.Clear();

        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "RequiresTwoFactor");

        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password, twoFactorCode }));
    }

    [Fact]
    public async Task CanLoginWithRecoveryCodeAndDisableTwoFactor()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });

        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);

        var twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync<JsonElement>();
        var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString();

        var keyBytes = Base32.FromBase32(sharedKey);
        var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(CultureInfo.InvariantCulture);

        var enable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true });
        var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());

        var recoveryCodes = enable2faContent.GetProperty("recoveryCodes").EnumerateArray().Select(e => e.GetString()).ToArray();
        Assert.Equal(10, recoveryCodes.Length);

        client.DefaultRequestHeaders.Clear();

        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "RequiresTwoFactor");

        var recoveryLoginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[0] });

        var recoveryLoginContent = await recoveryLoginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var recoveryAccessToken = recoveryLoginContent.GetProperty("accessToken").GetString();
        Assert.NotEqual(accessToken, recoveryAccessToken);

        client.DefaultRequestHeaders.Authorization = new("Bearer", recoveryAccessToken);

        var disable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { Enable = false });
        var disable2faContent = await disable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.False(disable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());

        client.DefaultRequestHeaders.Clear();

        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password }));
    }

    [Fact]
    public async Task CanResetSharedKey()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });

        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);

        var twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync<JsonElement>();
        var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString();

        var keyBytes = Base32.FromBase32(sharedKey);
        var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(CultureInfo.InvariantCulture);

        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true, ResetSharedKey = true }),
            "CannotResetSharedKeyAndEnable");

        var enable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true });
        var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());

        var resetKeyResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { ResetSharedKey = true });
        var resetKeyContent = await resetKeyResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.False(resetKeyContent.GetProperty("isTwoFactorEnabled").GetBoolean());

        var resetSharedKey = resetKeyContent.GetProperty("sharedKey").GetString();

        var resetKeyBytes = Base32.FromBase32(sharedKey);
        var resetTwoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(CultureInfo.InvariantCulture);

        // The old 2fa code no longer works
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true }),
            "InvalidTwoFactorCode");

        var reenable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { TwoFactorCode = resetTwoFactorCode, Enable = true });
        var reenable2faContent = await reenable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());
    }

    [Fact]
    public async Task CanResetRecoveryCodes()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password });

        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);

        var twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync<JsonElement>();
        var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString();

        var keyBytes = Base32.FromBase32(sharedKey);
        var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(CultureInfo.InvariantCulture);

        var enable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true });
        var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        var recoveryCodes = enable2faContent.GetProperty("recoveryCodes").EnumerateArray().Select(e => e.GetString()).ToArray();
        Assert.Equal(10, enable2faContent.GetProperty("recoveryCodesLeft").GetInt32());
        Assert.Equal(10, recoveryCodes.Length);

        client.DefaultRequestHeaders.Clear();

        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "RequiresTwoFactor");

        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[0] }));
        // Cannot reuse codes
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[0] }),
            "Failed");

        var recoveryLoginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[1] });
        var recoveryLoginContent = await recoveryLoginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var recoveryAccessToken = recoveryLoginContent.GetProperty("accessToken").GetString();
        Assert.NotEqual(accessToken, recoveryAccessToken);

        client.DefaultRequestHeaders.Authorization = new("Bearer", recoveryAccessToken);

        var updated2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var updated2faContent = await updated2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.Equal(8, updated2faContent.GetProperty("recoveryCodesLeft").GetInt32());
        Assert.Null(updated2faContent.GetProperty("recoveryCodes").GetString());

        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true, ResetSharedKey = true }),
            "CannotResetSharedKeyAndEnable");

        var resetRecoveryResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { ResetRecoveryCodes = true });
        var resetRecoveryContent = await resetRecoveryResponse.Content.ReadFromJsonAsync<JsonElement>();
        var resetRecoveryCodes = resetRecoveryContent.GetProperty("recoveryCodes").EnumerateArray().Select(e => e.GetString()).ToArray();
        Assert.Equal(10, resetRecoveryContent.GetProperty("recoveryCodesLeft").GetInt32());
        Assert.Equal(10, resetRecoveryCodes.Length);
        Assert.Empty(recoveryCodes.Intersect(resetRecoveryCodes));

        client.DefaultRequestHeaders.Clear();

        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = resetRecoveryCodes[0] }));

        // Even unused codes from before the reset now fail.
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[2] }),
            "Failed");
    }

    [Fact]
    public async Task CanUsePersistentTwoFactorCookies()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        var loginResponse = await client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email, Password });
        ApplyCookies(client, loginResponse);

        var twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.False(twoFactorKeyContent.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.False(twoFactorKeyContent.GetProperty("isMachineRemembered").GetBoolean());

        var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString();

        var keyBytes = Base32.FromBase32(sharedKey);
        var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        var timestep = Convert.ToInt64(unixTimestamp / 30);
        var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(CultureInfo.InvariantCulture);

        var enable2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new { twoFactorCode, Enable = true });
        var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.False(enable2faContent.GetProperty("isMachineRemembered").GetBoolean());

        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "RequiresTwoFactor");

        // The machine will not be remembered if useSessionCookies=true
        var sessionLoginResponse = await client.PostAsJsonAsync("/identity/login?useSessionCookies=true", new { Email, Password, twoFactorCode });
        ApplyCookies(client, sessionLoginResponse);

        var session2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var session2faContent = await session2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(session2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.False(session2faContent.GetProperty("isMachineRemembered").GetBoolean());

        // Even if useCookies=true also
        var sessionLoginResponse2 = await client.PostAsJsonAsync("/identity/login?useCookies=true&useSessionCookies=true", new { Email, Password, twoFactorCode });
        ApplyCookies(client, sessionLoginResponse2);

        var session2faResponse2 = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var session2faContent2 = await session2faResponse2.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(session2faContent2.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.False(session2faContent2.GetProperty("isMachineRemembered").GetBoolean());

        // But the machine will be remembered if just useCookies=true and not useSessionCookies=true
        var persistentLoginResponse = await client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email, Password, twoFactorCode });
        ApplyCookies(client, persistentLoginResponse);

        var persistent2faResponse = await client.PostAsJsonAsync("/identity/manage/2fa", new object());
        var persistent2faContent = await persistent2faResponse.Content.ReadFromJsonAsync<JsonElement>();
        Assert.True(persistent2faContent.GetProperty("isTwoFactorEnabled").GetBoolean());
        Assert.True(persistent2faContent.GetProperty("isMachineRemembered").GetBoolean());
    }

    [Fact]
    public async Task CanResetPassword()
    {
        var emailSender = new TestEmailSender();

        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedAccount = true;
            });
        });
        using var client = app.GetTestClient();

        var confirmedEmail = "confirmed@example.com";
        var unconfirmedEmail = "unconfirmed@example.com";

        await RegisterAsync(client, email: confirmedEmail);
        await LoginWithEmailConfirmationAsync(client, emailSender, email: confirmedEmail);

        await RegisterAsync(client, email: unconfirmedEmail);

        // Two emails were sent, but only one was confirmed
        Assert.Equal(2, emailSender.Emails.Count);

        // Returns 200 status for invalid email addresses
        AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/forgotPassword", new { Email = confirmedEmail }));
        AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/forgotPassword", new { Email = unconfirmedEmail }));
        AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/forgotPassword", new { Email = "wrong" }));

        // But only one email was sent for the confirmed address
        Assert.Equal(3, emailSender.Emails.Count);
        var resetEmail = emailSender.Emails[2];

        Assert.Equal("Reset your password", resetEmail.Subject);
        Assert.Equal(confirmedEmail, resetEmail.Address);

        var resetCode = GetPasswordResetCode(resetEmail);
        var newPassword = $"{Password}!";

        // The same validation errors are returned even for invalid emails
        AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, resetCode }));
        AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = unconfirmedEmail, resetCode }));
        AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = "wrong", resetCode }));

        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, ResetCode = "wrong", newPassword }),
            "InvalidToken");
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = unconfirmedEmail, ResetCode = "wrong", newPassword }),
            "InvalidToken");
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = "wrong", ResetCode = "wrong", newPassword }),
            "InvalidToken");

        // Only with a valid reset code is it possible to get more problem details
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, ResetCode = "wrong", NewPassword = "" }),
            "InvalidToken");
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, resetCode, NewPassword = "" }),
            detail: null, title: "One or more validation errors occurred.", status: HttpStatusCode.BadRequest);

        AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, resetCode, newPassword }));

        // The old password is no longer valid
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email = confirmedEmail, Password }),
            "Failed");

        // But the new password is
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email = confirmedEmail, Password = newPassword }));
    }

    [Theory]
    [MemberData(nameof(AddIdentityModes))]
    public async Task CanChangeEmail(string addIdentityModes)
    {
        var emailSender = new TestEmailSender();

        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityActions[addIdentityModes](services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedAccount = true;
            });
        });
        using var client = app.GetTestClient();

        AssertUnauthorizedAndEmpty(await client.GetAsync("/identity/manage/info"));

        await RegisterAsync(client);
        var originalRefreshToken = await LoginWithEmailConfirmationAsync(client, emailSender);

        var infoResponse = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        Assert.Equal(Email, infoResponse.GetProperty("email").GetString());
        Assert.True(infoResponse.GetProperty("isEmailConfirmed").GetBoolean());

        var infoClaims = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal("pwd", GetSingleClaim(infoClaims, "amr"));
        Assert.Equal(Email, GetSingleClaim(infoClaims, ClaimTypes.Name));
        Assert.Equal(Email, GetSingleClaim(infoClaims, ClaimTypes.Email));

        var originalNameIdentifier = GetSingleClaim(infoClaims, ClaimTypes.NameIdentifier);
        var newEmail = $"New-{Email}";

        // The email must pass DataAnnotations validation by EmailAddressAttribute.
        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/info", new { NewEmail = "invalid" }),
            "InvalidEmail");

        var infoPostResponse = await client.PostAsJsonAsync("/identity/manage/info", new { newEmail });
        var infoPostContent = await infoPostResponse.Content.ReadFromJsonAsync<JsonElement>();
        // The email isn't updated until the new email is confirmed.
        Assert.Equal(Email, infoPostContent.GetProperty("email").GetString());
        Assert.True(infoPostContent.GetProperty("isEmailConfirmed").GetBoolean());

        // And none of the claims have yet been updated.
        var infoPostClaims = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal(Email, GetSingleClaim(infoPostClaims, ClaimTypes.Name));
        Assert.Equal(Email, GetSingleClaim(infoPostClaims, ClaimTypes.Email));
        Assert.Equal(originalNameIdentifier, GetSingleClaim(infoPostClaims, ClaimTypes.NameIdentifier));

        // We cannot log in with the new email until we confirm the email change.
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email = newEmail, Password }),
            "Failed");

        // And we can still use the original refresh token since the email change has not yet been confirmed.
        AssertOk(await client.PostAsJsonAsync("/identity/refresh", new { RefreshToken = originalRefreshToken }));

        // Two emails have now been sent. The first was sent during registration. And the second for the email change.
        Assert.Equal(2, emailSender.Emails.Count);
        var email = emailSender.Emails[1];

        Assert.Equal("Confirm your email", email.Subject);
        Assert.Equal(newEmail, email.Address);

        AssertOk(await client.GetAsync(GetEmailConfirmationLink(email)));

        var infoAfterEmailChange = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        // The email is immediately updated after the email is confirmed.
        Assert.Equal(newEmail, infoAfterEmailChange.GetProperty("email").GetString());

        // The email still won't be available as a claim until we get a new token.
        var claimsAfterEmailChange = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal(Email, GetSingleClaim(claimsAfterEmailChange, ClaimTypes.Name));
        Assert.Equal(Email, GetSingleClaim(claimsAfterEmailChange, ClaimTypes.Email));
        Assert.Equal(originalNameIdentifier, GetSingleClaim(claimsAfterEmailChange, ClaimTypes.NameIdentifier));

        // And now the email has changed, the refresh token is invalidated by the security stamp.
        AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { RefreshToken = originalRefreshToken }));

        // We will finally see all the claims updated after logging in again.
        await LoginAsync(client, email: newEmail);

        var infoAfterFinalLogin = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        Assert.Equal(newEmail, infoAfterFinalLogin.GetProperty("email").GetString());
        Assert.True(infoAfterFinalLogin.GetProperty("isEmailConfirmed").GetBoolean());

        var claimsAfterFinalLogin = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal(newEmail, GetSingleClaim(claimsAfterFinalLogin, ClaimTypes.Name));
        Assert.Equal(newEmail, GetSingleClaim(claimsAfterFinalLogin, ClaimTypes.Email));
        Assert.Equal(originalNameIdentifier, GetSingleClaim(claimsAfterFinalLogin, ClaimTypes.NameIdentifier));
    }

    [Fact]
    public async Task CannotUpdateClaimsDuringInfoPostWithCookies()
    {
        var emailSender = new TestEmailSender();

        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
            services.Configure<IdentityOptions>(options =>
            {
                options.SignIn.RequireConfirmedAccount = true;
            });
        });
        using var client = app.GetTestClient();

        AssertUnauthorizedAndEmpty(await client.GetAsync("/identity/manage/info"));

        await RegisterAsync(client);
        await LoginWithEmailConfirmationAsync(client, emailSender);

        // Clear bearer token. We just used the common login email for convenient email verification.
        client.DefaultRequestHeaders.Clear();
        var loginResponse = await client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email, Password });
        ApplyCookies(client, loginResponse);

        var infoResponse = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        Assert.Equal(Email, infoResponse.GetProperty("email").GetString());

        var infoClaims = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal("pwd", GetSingleClaim(infoClaims, "amr"));
        Assert.Equal(Email, GetSingleClaim(infoClaims, ClaimTypes.Name));
        Assert.Equal(Email, GetSingleClaim(infoClaims, ClaimTypes.Email));

        var originalNameIdentifier = GetSingleClaim(infoClaims, ClaimTypes.NameIdentifier);
        var newEmail = $"NewEmailPrefix-{Email}";

        var infoPostResponse = await client.PostAsJsonAsync("/identity/manage/info", new { newEmail });
        // There are no cookie updates because nothing has changed yet.
        Assert.False(infoPostResponse.Headers.Contains(HeaderNames.SetCookie));

        var infoPostContent = await infoPostResponse.Content.ReadFromJsonAsync<JsonElement>();
        // The email isn't updated until the email is confirmed.
        Assert.Equal(Email, infoPostContent.GetProperty("email").GetString());

        // The claims have not been updated to match.
        var infoPostClaims = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal(Email, GetSingleClaim(infoPostClaims, ClaimTypes.Email));
        Assert.Equal(originalNameIdentifier, GetSingleClaim(infoPostClaims, ClaimTypes.NameIdentifier));

        // Two emails have now been sent. The first was sent during registration. And the second for the email change.
        Assert.Equal(2, emailSender.Emails.Count);
        var email = emailSender.Emails[1];

        Assert.Equal("Confirm your email", email.Subject);
        Assert.Equal(newEmail, email.Address);

        var emailConfirmationResponse = await client.GetAsync(GetEmailConfirmationLink(email));
        // Even though the user does change during this request, we still don't refresh the cookie, because this
        // request doesn't rely on authentication. It's entirely possible the client is logged in as a different user.
        Assert.False(emailConfirmationResponse.Headers.Contains(HeaderNames.SetCookie));
        AssertOk(emailConfirmationResponse);

        var infoAfterEmailChange = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        // The email is immediately updated after the email is confirmed.
        Assert.Equal(newEmail, infoAfterEmailChange.GetProperty("email").GetString());

        // The email still won't be available as a claim until we get a new cookie.
        var claimsAfterEmailChange = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal(Email, GetSingleClaim(claimsAfterEmailChange, ClaimTypes.Email));
        Assert.Equal(originalNameIdentifier, GetSingleClaim(claimsAfterEmailChange, ClaimTypes.NameIdentifier));

        // We will finally see all the claims updated after logging in again.
        var secondLoginResponse = await client.PostAsJsonAsync("/identity/login?useCookies=true", new { Email = newEmail, Password });
        ApplyCookies(client, secondLoginResponse);

        var infoAfterFinalLogin = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        Assert.Equal(newEmail, infoAfterFinalLogin.GetProperty("email").GetString());

        var claimsAfterFinalLogin = await client.GetFromJsonAsync<JsonElement>("/auth/claims");
        Assert.Equal(newEmail, GetSingleClaim(claimsAfterFinalLogin, ClaimTypes.Name));
        Assert.Equal(newEmail, GetSingleClaim(claimsAfterFinalLogin, ClaimTypes.Email));
        Assert.Equal(originalNameIdentifier, GetSingleClaim(claimsAfterFinalLogin, ClaimTypes.NameIdentifier));
    }

    [Fact]
    public async Task CanChangePasswordWithoutResetEmail()
    {
        await using var app = await CreateAppAsync();
        using var client = app.GetTestClient();

        await RegisterAsync(client);
        await LoginAsync(client);

        var newPassword = $"{Password}!";

        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/info", new { newPassword }),
            "OldPasswordRequired");
        AssertOk(await client.PostAsJsonAsync("/identity/manage/info", new { OldPassword = Password, newPassword }));

        client.DefaultRequestHeaders.Clear();

        // We can immediately log in with the new password
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "Failed");
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password = newPassword }));
    }

    [Fact]
    public async Task MustSendValidRequestToSendEmailChangeConfirmation()
    {
        var emailSender = new TestEmailSender();

        await using var app = await CreateAppAsync(services =>
        {
            AddIdentityApiEndpoints(services);
            services.AddSingleton<IEmailSender>(emailSender);
        });
        using var client = app.GetTestClient();

        await RegisterAsync(client);

        // We're not going to bother to confirm the original email, but it should be there.
        Assert.Single(emailSender.Emails);
        emailSender.Emails.Clear();

        await LoginAsync(client);

        var newEmail = $"New-{Email}";
        var newPassword = $"{Password}!";

        await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/manage/info", new { newPassword, newEmail }),
            "OldPasswordRequired");

        // Since the request is invalid, no change email confirmation was sent.
        Assert.Empty(emailSender.Emails);

        // We can in fact update multiple things at once if we do it correctly, though the response wont show a email update until we confirm the email.
        var infoPostResponse = await client.PostAsJsonAsync("/identity/manage/info", new { OldPassword = Password, newPassword, newEmail });

        var infoPostContent = await infoPostResponse.Content.ReadFromJsonAsync<JsonElement>();
        // The email isn't updated until the email is confirmed.
        Assert.Equal(Email, infoPostContent.GetProperty("email").GetString());
        Assert.False(infoPostContent.GetProperty("isEmailConfirmed").GetBoolean());

        // We cannot login with the new email yet.
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email = newEmail, Password = newPassword }),
            "Failed");
        // And we cannot login with the old email and password either.
        await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }),
            "Failed");
        // We'll have to use the old email with the new password until we confirm the new email.
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password = newPassword }));

        // Confirm the email change.
        var changeEmail = Assert.Single(emailSender.Emails);
        Assert.Equal(newEmail, changeEmail.Address);
        AssertOk(await client.GetAsync(GetEmailConfirmationLink(changeEmail)));

        var infoGetContent = await client.GetFromJsonAsync<JsonElement>("/identity/manage/info");
        // The email isn't updated until the email is confirmed.
        Assert.Equal(newEmail, infoGetContent.GetProperty("email").GetString());
        Assert.True(infoGetContent.GetProperty("isEmailConfirmed").GetBoolean());

        // We can now login with the new email too.
        AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email = newEmail, Password = newPassword }));
    }

    private async Task<WebApplication> CreateAppAsync<TUser, TContext>(Action<IServiceCollection>? configureServices, bool autoStart = true)
        where TUser : class, new()
        where TContext : DbContext
    {
        var builder = WebApplication.CreateSlimBuilder();
        builder.WebHost.UseTestServer(options =>
        {
            options.BaseAddress = BaseAddress;
        });
        builder.Services.AddSingleton(LoggerFactory);
        builder.Services.AddAuthorization();

        var dbConnection = new SqliteConnection("DataSource=:memory:");
        // Dispose SqliteConnection with host by registering as a singleton factory.
        builder.Services.AddSingleton(_ => dbConnection);

        configureServices ??= services => AddIdentityApiEndpoints<TUser, TContext>(services);
        configureServices(builder.Services);

        var app = builder.Build();

        app.UseAuthentication();
        app.UseAuthorization();

        app.MapGroup("/identity").MapIdentityApi<TUser>();

        var authGroup = app.MapGroup("/auth").RequireAuthorization();
        authGroup.MapGet("/hello",
            (ClaimsPrincipal user) => $"Hello, {user.Identity?.Name}!");

        authGroup.MapGet("/claims", (ClaimsPrincipal user) => user.Claims.Select(c => new { c.Type, c.Value }));

        await dbConnection.OpenAsync();
        await app.Services.GetRequiredService<TContext>().Database.EnsureCreatedAsync();

        if (autoStart)
        {
            await app.StartAsync();
        }

        return app;
    }

    private static IdentityBuilder AddIdentityApiEndpoints<TUser, TContext>(IServiceCollection services)
        where TUser : class, new()
        where TContext : DbContext
    {
        return services.AddDbContext<TContext>((sp, options) => options.UseSqlite(sp.GetRequiredService<SqliteConnection>()))
            .AddIdentityApiEndpoints<TUser>().AddEntityFrameworkStores<TContext>();
    }

    private static IdentityBuilder AddIdentityApiEndpoints(IServiceCollection services)
        => AddIdentityApiEndpoints<ApplicationUser, ApplicationDbContext>(services);

    private static IdentityBuilder AddIdentityApiEndpointsBearerOnly(IServiceCollection services)
    {
        services
            .AddAuthentication()
            .AddBearerToken(IdentityConstants.BearerScheme);

        return services
            .AddDbContext<ApplicationDbContext>((sp, options) => options.UseSqlite(sp.GetRequiredService<SqliteConnection>()))
            .AddIdentityCore<ApplicationUser>()
            .AddEntityFrameworkStores<ApplicationDbContext>()
            .AddApiEndpoints();
    }

    private Task<WebApplication> CreateAppAsync(Action<IServiceCollection>? configureServices = null)
        => CreateAppAsync<ApplicationUser, ApplicationDbContext>(configureServices);

    private static Dictionary<string, Action<IServiceCollection>> AddIdentityActions { get; } = new()
    {
        [nameof(AddIdentityApiEndpoints)] = services => AddIdentityApiEndpoints(services),
        [nameof(AddIdentityApiEndpointsBearerOnly)] = services => AddIdentityApiEndpointsBearerOnly(services),
    };

    public static object[][] AddIdentityModes => AddIdentityActions.Keys.Select(key => new object[] { key }).ToArray();

    private static string? GetSingleClaim(JsonElement claims, string name)
        => claims.EnumerateArray().Single(e => e.GetProperty("type").GetString() == name).GetProperty("value").GetString();

    private static string GetEmailConfirmationLink(TestEmail email)
    {
        // Update if we add more links to the email.
        var confirmationMatch = Regex.Match(email.HtmlMessage, "href='(.*?)'");
        Assert.True(confirmationMatch.Success);
        Assert.Equal(2, confirmationMatch.Groups.Count);

        var url = WebUtility.HtmlDecode(confirmationMatch.Groups[1].Value);
        Assert.StartsWith(BaseAddress.ToString(), url);
        return url;
    }

    private static string GetPasswordResetCode(TestEmail email)
    {
        // Update if we add more links to the email.
        var confirmationMatch = Regex.Match(email.HtmlMessage, "code: (.*?)$");
        Assert.True(confirmationMatch.Success);
        Assert.Equal(2, confirmationMatch.Groups.Count);

        return WebUtility.HtmlDecode(confirmationMatch.Groups[1].Value);
    }

    private async Task RegisterAsync(HttpClient client, string? groupPrefix = null, string? email = null)
    {
        groupPrefix ??= "/identity";
        email ??= Email;

        AssertOkAndEmpty(await client.PostAsJsonAsync($"{groupPrefix}/register", new { email, Password }));
    }

    private async Task<string> LoginAsync(HttpClient client, string? groupPrefix = null, string? email = null)
    {
        groupPrefix ??= "/identity";
        email ??= Email;

        await client.PostAsJsonAsync($"{groupPrefix}/login", new { email, Password });
        var loginResponse = await client.PostAsJsonAsync("/identity/login", new { email, Password });
        var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
        var accessToken = loginContent.GetProperty("accessToken").GetString();
        var refreshToken = loginContent.GetProperty("refreshToken").GetString();
        Assert.NotNull(accessToken);
        Assert.NotNull(refreshToken);
        client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);

        return refreshToken;
    }

    private async Task<string> LoginWithEmailConfirmationAsync(HttpClient client, TestEmailSender emailSender, string? groupPrefix = null, string? email = null)
    {
        groupPrefix ??= "/identity";
        email ??= Email;

        var receivedEmail = emailSender.Emails.Last();

        Assert.Equal("Confirm your email", receivedEmail.Subject);
        Assert.Equal(email, receivedEmail.Address);

        await AssertProblemAsync(await client.PostAsJsonAsync($"{groupPrefix}/login", new { email, Password }),
            "NotAllowed");

        AssertOk(await client.GetAsync(GetEmailConfirmationLink(receivedEmail)));

        return await LoginAsync(client, groupPrefix, email);
    }

    private static void AssertOk(HttpResponseMessage response)
    {
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }

    private static void AssertOkAndEmpty(HttpResponseMessage response)
    {
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal(0, response.Content.Headers.ContentLength);
    }

    private static void AssertBadRequestAndEmpty(HttpResponseMessage response)
    {
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.Equal(0, response.Content.Headers.ContentLength);
    }

    private static void AssertUnauthorizedAndEmpty(HttpResponseMessage response)
    {
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
        Assert.Equal(0, response.Content.Headers.ContentLength);
    }

    private static async Task AssertProblemAsync(HttpResponseMessage response, string? detail, string? title = null, HttpStatusCode status = HttpStatusCode.Unauthorized)
    {
        Assert.Equal(status, response.StatusCode);
        Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.ToString());
        var problem = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>();
        Assert.NotNull(problem);
        Assert.Equal(title ?? ReasonPhrases.GetReasonPhrase((int)status), problem.Title);
        Assert.Equal(detail, problem.Detail);
    }

    private static async Task AssertValidationProblemAsync(HttpResponseMessage response, string error)
    {
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.ToString());
        var problem = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>();
        Assert.NotNull(problem);
        var errorEntry = Assert.Single(problem.Errors);
        Assert.Equal(error, errorEntry.Key);
    }

    private static void ApplyCookies(HttpClient client, HttpResponseMessage response)
    {
        AssertOk(response);

        Assert.True(response.Headers.TryGetValues(HeaderNames.SetCookie, out var setCookieHeaders));
        foreach (var setCookieHeader in setCookieHeaders)
        {
            if (setCookieHeader.Split(';', 2) is not [var cookie, _])
            {
                throw new XunitException("Invalid Set-Cookie header!");
            }

            // Cookies starting with "CookieName=;" are being deleted
            if (!cookie.EndsWith("=", StringComparison.Ordinal))
            {
                client.DefaultRequestHeaders.Add(HeaderNames.Cookie, cookie);
            }
        }
    }

    private sealed class TestTokenProvider<TUser> : IUserTwoFactorTokenProvider<TUser>
        where TUser : class
    {
        public async Task<string> GenerateAsync(string purpose, UserManager<TUser> manager, TUser user)
        {
            return MakeToken(purpose, await manager.GetUserIdAsync(user));
        }

        public async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> manager, TUser user)
        {
            return token == MakeToken(purpose, await manager.GetUserIdAsync(user));
        }

        public Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<TUser> manager, TUser user)
        {
            return Task.FromResult(true);
        }

        private static string MakeToken(string purpose, string userId)
        {
            return string.Join(":", userId, purpose, "ImmaToken");
        }
    }

    private sealed class TestEmailSender : IEmailSender
    {
        public List<TestEmail> Emails { get; set; } = new();

        public Task SendEmailAsync(string email, string subject, string htmlMessage)
        {
            Emails.Add(new(email, subject, htmlMessage));
            return Task.CompletedTask;
        }
    }

    private sealed class TestCustomEmailSender(IEmailSender emailSender) : IEmailSender<ApplicationUser>
    {
        public const string CustomSubject = "Custom subject";
        public const string CustomMessage = "Custom message";

        public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink)
        {
            Assert.Equal(user.Email, email);
            emailSender.SendEmailAsync(email, "Custom subject", "Custom message");
            return Task.CompletedTask;
        }

        public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
            throw new NotImplementedException();

        public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
            throw new NotImplementedException();
    }

    private sealed record TestEmail(string Address, string Subject, string HtmlMessage);
}
