From 77a48ea4e98e47b88fdd819535eebd35c0c029cc Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 23 Dec 2024 12:16:05 +0100 Subject: [PATCH] Refactor admin so all tests pass (#190) --- src/AliasVault.Admin/Auth/Pages/Logout.razor | 9 ++- .../WorkerStatus/ServiceControl.razor | 8 +- .../Pages/Account/Manage/ChangePassword.razor | 25 +++--- .../Pages/Account/Manage/Disable2fa.razor | 16 +++- .../Account/Manage/EnableAuthenticator.razor | 50 ++++++++---- .../Manage/GenerateRecoveryCodes.razor | 25 ++++-- .../Account/Manage/ResetAuthenticator.razor | 13 ++- .../Manage/TwoFactorAuthentication.razor | 30 ++++--- src/AliasVault.Admin/Main/Pages/MainBase.cs | 9 --- .../Components/TaskRunnerHistory.razor | 2 +- .../Main/Pages/Settings/Server.razor | 2 +- src/AliasVault.Admin/Services/UserService.cs | 8 +- .../WebApplicationAdminFactoryFixture.cs | 80 +++++++++++++----- .../WebApplicationApiFactoryFixture.cs | 81 ++++++++++++++----- .../Tests/Admin/ServerSettingsTests.cs | 5 +- .../Tests/Admin/TwoFactorAuthLockoutTests.cs | 3 + 16 files changed, 247 insertions(+), 119 deletions(-) diff --git a/src/AliasVault.Admin/Auth/Pages/Logout.razor b/src/AliasVault.Admin/Auth/Pages/Logout.razor index 3fc33fdbe..abaa33068 100644 --- a/src/AliasVault.Admin/Auth/Pages/Logout.razor +++ b/src/AliasVault.Admin/Auth/Pages/Logout.razor @@ -8,11 +8,13 @@ protected override async Task OnInitializedAsync() { // Sign out the user. - // NOTE: the try/catch below is a workaround for the issue that the sign out does not work when + // NOTE: the try/catch below is a workaround for the issue that the sign-out does not work when // the server session is already started. try { + await UserService.LoadCurrentUserAsync(); var username = UserService.User().UserName; + try { await SignInManager.SignOutAsync(); @@ -22,11 +24,12 @@ // Redirect to the home page with hard refresh. NavigationService.RedirectTo("/", true); } - catch + catch (Exception ex) { // Hard refresh current page if sign out fails. When an interactive server session is already started - // the sign out will fail because it tries to mutate cookies which is only possible when the server + // the sign-out will fail because it tries to mutate cookies which is only possible when the server // session is not started yet. + Console.WriteLine(ex); await AuthLoggingService.LogAuthEventSuccessAsync(username!, AuthEventType.Logout); NavigationService.RedirectTo(NavigationService.Uri, true); } diff --git a/src/AliasVault.Admin/Main/Components/WorkerStatus/ServiceControl.razor b/src/AliasVault.Admin/Main/Components/WorkerStatus/ServiceControl.razor index e4833d0b3..55ed17aa3 100644 --- a/src/AliasVault.Admin/Main/Components/WorkerStatus/ServiceControl.razor +++ b/src/AliasVault.Admin/Main/Components/WorkerStatus/ServiceControl.razor @@ -171,7 +171,7 @@ try { InitInProgress = true; - var dbContext = await DbContextFactory.CreateDbContextAsync(); + await using var dbContext = await DbContextFactory.CreateDbContextAsync(); ServiceStatus = await dbContext.WorkerServiceStatuses.ToListAsync(); foreach (var service in Services) @@ -197,7 +197,7 @@ /// private async Task UpdateServiceStatus(string serviceName, bool newStatus) { - var dbContext = await DbContextFactory.CreateDbContextAsync(); + await using var dbContext = await DbContextFactory.CreateDbContextAsync(); var entry = await dbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstOrDefaultAsync(); if (entry != null) { @@ -213,8 +213,8 @@ return false; } - dbContext = await DbContextFactory.CreateDbContextAsync(); - var check = await dbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstAsync(); + await using var dbContextInner = await DbContextFactory.CreateDbContextAsync(); + var check = await dbContextInner.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstAsync(); if (check.CurrentStatus == newDesiredStatus) { return true; diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/ChangePassword.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/ChangePassword.razor index 8aa519f6b..90cb93453 100644 --- a/src/AliasVault.Admin/Main/Pages/Account/Manage/ChangePassword.razor +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/ChangePassword.razor @@ -2,7 +2,6 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Identity - @inject UserManager UserManager @inject ILogger Logger @@ -41,15 +40,13 @@ private async Task OnValidSubmitAsync() { - var changePasswordResult = await UserManager.ChangePasswordAsync(UserService.User(), Input.OldPassword, Input.NewPassword); - var user = UserService.User(); - user.LastPasswordChanged = DateTime.UtcNow; - await UserService.UpdateUserAsync(user); + var user = await UserManager.FindByIdAsync(UserService.User().Id); + if (user == null) + { + throw new InvalidOperationException("User not found."); + } - // Clear the password fields - Input.OldPassword = ""; - Input.NewPassword = ""; - Input.ConfirmPassword = ""; + var changePasswordResult = await UserManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword); if (!changePasswordResult.Succeeded) { @@ -57,10 +54,15 @@ return; } + user.LastPasswordChanged = DateTime.UtcNow; + await UserManager.UpdateAsync(user); + + Input.OldPassword = ""; + Input.NewPassword = ""; + Input.ConfirmPassword = ""; + Logger.LogInformation("User changed their password successfully."); - GlobalNotificationService.AddSuccessMessage("Your password has been changed."); - NavigationService.RedirectToCurrentPage(); } @@ -82,5 +84,4 @@ [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] public string ConfirmPassword { get; set; } = ""; } - } diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/Disable2fa.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/Disable2fa.razor index 8eb7cb96b..c06a4fb32 100644 --- a/src/AliasVault.Admin/Main/Pages/Account/Manage/Disable2fa.razor +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/Disable2fa.razor @@ -31,7 +31,13 @@ /// protected override async Task OnInitializedAsync() { - if (!await UserManager.GetTwoFactorEnabledAsync(UserService.User())) + var user = await UserManager.FindByIdAsync(UserService.User().Id); + if (user == null) + { + throw new InvalidOperationException("User not found."); + } + + if (!await UserManager.GetTwoFactorEnabledAsync(user)) { throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled."); } @@ -39,7 +45,13 @@ private async Task OnSubmitAsync() { - var disable2FaResult = await UserManager.SetTwoFactorEnabledAsync(UserService.User(), false); + var user = await UserManager.FindByIdAsync(UserService.User().Id); + if (user == null) + { + throw new InvalidOperationException("User not found."); + } + + var disable2FaResult = await UserManager.SetTwoFactorEnabledAsync(user, false); if (!disable2FaResult.Succeeded) { await AuthLoggingService.LogAuthEventFailAsync(UserService.User().UserName!, AuthEventType.TwoFactorAuthDisable, AuthFailureReason.Unknown); diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/EnableAuthenticator.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/EnableAuthenticator.razor index e2f3c539b..f322c41c9 100644 --- a/src/AliasVault.Admin/Main/Pages/Account/Manage/EnableAuthenticator.razor +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/EnableAuthenticator.razor @@ -13,6 +13,12 @@ Configure authenticator app +@if (_isLoading) +{ + + return; +} + @if (RecoveryCodes is not null) { @@ -69,15 +75,20 @@ else private string? SharedKey { get; set; } private string? AuthenticatorUri { get; set; } private IEnumerable? RecoveryCodes { get; set; } + private bool _isLoading = true; [SupplyParameterFromForm] private InputModel Input { get; set; } = new(); - /// - protected override async Task OnInitializedAsync() + /// + protected override async Task OnAfterRenderAsync(bool firstRender) { - await base.OnInitializedAsync(); - await LoadSharedKeyAndQrCodeUriAsync(UserService.User()); - await JsInvokeService.RetryInvokeAsync("generateQrCode", TimeSpan.Zero, 5, "authenticator-uri"); + if (firstRender) + { + await LoadSharedKeyAndQrCodeUriAsync(); + _isLoading = false; + StateHasChanged(); + await JsInvokeService.RetryInvokeAsync("generateQrCode", TimeSpan.Zero, 5, "authenticator-uri"); + } } private async Task OnValidSubmitAsync() @@ -85,8 +96,13 @@ else // Strip spaces and hyphens var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty); - var is2FaTokenValid = await UserManager.VerifyTwoFactorTokenAsync( - UserService.User(), UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode); + var user = await UserManager.FindByIdAsync(UserService.User().Id); + if (user == null) + { + throw new InvalidOperationException("User not found."); + } + + var is2FaTokenValid = await UserManager.VerifyTwoFactorTokenAsync(user, UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode); if (!is2FaTokenValid) { @@ -94,25 +110,31 @@ else return; } - await UserManager.SetTwoFactorEnabledAsync(UserService.User(), true); + await UserManager.SetTwoFactorEnabledAsync(user, true); await AuthLoggingService.LogAuthEventSuccessAsync(UserService.User().UserName!, AuthEventType.TwoFactorAuthEnable); Logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", UserService.User().Id); GlobalNotificationService.AddSuccessMessage("Your authenticator app has been verified."); - if (await UserManager.CountRecoveryCodesAsync(UserService.User()) == 0) + if (await UserManager.CountRecoveryCodesAsync(user) == 0) { - RecoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10); + RecoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); } else { - // Navigate back to the two factor authentication page. + // Navigate back to the two-factor authentication page. NavigationService.RedirectTo("account/manage/2fa", forceLoad: true); } } - private async ValueTask LoadSharedKeyAndQrCodeUriAsync(AdminUser user) + private async ValueTask LoadSharedKeyAndQrCodeUriAsync() { - // Load the authenticator key & QR code URI to display on the form + var user = await UserManager.FindByIdAsync(UserService.User().Id); + if (user == null) + { + throw new InvalidOperationException("User not found."); + } + + // Load the authenticator key & QR code URI to display on the form. var unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user); if (string.IsNullOrEmpty(unformattedKey)) { @@ -126,7 +148,7 @@ else AuthenticatorUri = GenerateQrCodeUri(username!, unformattedKey!); } - private string FormatKey(string unformattedKey) + private static string FormatKey(string unformattedKey) { var result = new StringBuilder(); int currentPosition = 0; diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/GenerateRecoveryCodes.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/GenerateRecoveryCodes.razor index b33340c08..67e10222d 100644 --- a/src/AliasVault.Admin/Main/Pages/Account/Manage/GenerateRecoveryCodes.razor +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/GenerateRecoveryCodes.razor @@ -7,9 +7,9 @@ Generate two-factor authentication (2FA) recovery codes -@if (recoveryCodes is not null) +@if (_recoveryCodes is not null) { - + } else { @@ -35,14 +35,20 @@ else } @code { - private IEnumerable? recoveryCodes; + private IEnumerable? _recoveryCodes; /// protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(UserService.User()); + var user = await UserManager.FindByIdAsync(UserService.User().Id); + if (user == null) + { + throw new InvalidOperationException("User not found."); + } + + var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(user); if (!isTwoFactorEnabled) { throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled."); @@ -51,11 +57,16 @@ else private async Task GenerateCodes() { - var userId = await UserManager.GetUserIdAsync(UserService.User()); - recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10); + var user = await UserManager.FindByIdAsync(UserService.User().Id); + if (user == null) + { + throw new InvalidOperationException("User not found."); + } + + _recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); GlobalNotificationService.AddSuccessMessage("You have generated new recovery codes."); - Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId); + Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", UserService.User().Id); } } diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/ResetAuthenticator.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/ResetAuthenticator.razor index 708a355a4..fda5231e7 100644 --- a/src/AliasVault.Admin/Main/Pages/Account/Manage/ResetAuthenticator.razor +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/ResetAuthenticator.razor @@ -30,10 +30,15 @@ @code { private async Task OnSubmitAsync() { - await UserManager.SetTwoFactorEnabledAsync(UserService.User(), false); - await UserManager.ResetAuthenticatorKeyAsync(UserService.User()); - var userId = await UserManager.GetUserIdAsync(UserService.User()); - Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId); + var user = await UserManager.FindByIdAsync(UserService.User().Id); + if (user == null) + { + throw new InvalidOperationException("User not found."); + } + + await UserManager.SetTwoFactorEnabledAsync(user, false); + await UserManager.ResetAuthenticatorKeyAsync(user); + Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", UserService.User().Id); GlobalNotificationService.AddSuccessMessage("Your authenticator app key has been reset, you will need to re-configure your authenticator app using the new key."); diff --git a/src/AliasVault.Admin/Main/Pages/Account/Manage/TwoFactorAuthentication.razor b/src/AliasVault.Admin/Main/Pages/Account/Manage/TwoFactorAuthentication.razor index de59bcb54..bc3cdd63f 100644 --- a/src/AliasVault.Admin/Main/Pages/Account/Manage/TwoFactorAuthentication.razor +++ b/src/AliasVault.Admin/Main/Pages/Account/Manage/TwoFactorAuthentication.razor @@ -5,29 +5,29 @@ Two-factor authentication (2FA) -@if (is2FaEnabled) +@if (_is2FaEnabled) {

Two-factor authentication (2FA)

- @if (recoveryCodesLeft == 0) + @if (_recoveryCodesLeft == 0) {

You have no recovery codes left.

You must generate a new set of recovery codes before you can log in with a recovery code.

} - else if (recoveryCodesLeft == 1) + else if (_recoveryCodesLeft == 1) {

You have 1 recovery code left.

You can generate a new set of recovery codes.

} - else if (recoveryCodesLeft <= 3) + else if (_recoveryCodesLeft <= 3) {
-

You have @recoveryCodesLeft recovery codes left.

+

You have @_recoveryCodesLeft recovery codes left.

You should generate a new set of recovery codes.

} @@ -42,7 +42,7 @@

Authenticator app

- @if (!hasAuthenticator) + @if (!_hasAuthenticator) { } @@ -55,17 +55,23 @@
@code { - private bool hasAuthenticator; - private int recoveryCodesLeft; - private bool is2FaEnabled; + private bool _hasAuthenticator; + private int _recoveryCodesLeft; + private bool _is2FaEnabled; /// protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(UserService.User()) is not null; - is2FaEnabled = await UserManager.GetTwoFactorEnabledAsync(UserService.User()); - recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(UserService.User()); + var user = await UserManager.FindByIdAsync(UserService.User().Id); + if (user == null) + { + throw new InvalidOperationException("User not found."); + } + + _hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(user) is not null; + _is2FaEnabled = await UserManager.GetTwoFactorEnabledAsync(user); + _recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(user); } } diff --git a/src/AliasVault.Admin/Main/Pages/MainBase.cs b/src/AliasVault.Admin/Main/Pages/MainBase.cs index 07cf8832f..a83febf47 100644 --- a/src/AliasVault.Admin/Main/Pages/MainBase.cs +++ b/src/AliasVault.Admin/Main/Pages/MainBase.cs @@ -84,15 +84,6 @@ public abstract class MainBase : OwningComponentBase /// protected List BreadcrumbItems { get; } = new(); - /// - /// Gets the AliasServerDbContext instance asynchronously. - /// - /// The AliasServerDbContext instance. - protected async Task GetDbContextAsync() - { - return await DbContextFactory.CreateDbContextAsync(); - } - /// protected override async Task OnInitializedAsync() { diff --git a/src/AliasVault.Admin/Main/Pages/Settings/Components/TaskRunnerHistory.razor b/src/AliasVault.Admin/Main/Pages/Settings/Components/TaskRunnerHistory.razor index a87726f7a..f6a43b3e5 100644 --- a/src/AliasVault.Admin/Main/Pages/Settings/Components/TaskRunnerHistory.razor +++ b/src/AliasVault.Admin/Main/Pages/Settings/Components/TaskRunnerHistory.razor @@ -64,7 +64,7 @@ /// public async Task RefreshData() { - var dbContext = await DbContextFactory.CreateDbContextAsync(); + await using var dbContext = await DbContextFactory.CreateDbContextAsync(); var query = dbContext.TaskRunnerJobs.AsQueryable(); // Apply sorting diff --git a/src/AliasVault.Admin/Main/Pages/Settings/Server.razor b/src/AliasVault.Admin/Main/Pages/Settings/Server.razor index 1c0676f6a..f03bb3593 100644 --- a/src/AliasVault.Admin/Main/Pages/Settings/Server.razor +++ b/src/AliasVault.Admin/Main/Pages/Settings/Server.razor @@ -122,7 +122,7 @@ { try { - var dbContext = await DbContextFactory.CreateDbContextAsync(); + await using var dbContext = await DbContextFactory.CreateDbContextAsync(); var job = new TaskRunnerJob { Name = nameof(TaskRunnerJobType.Maintenance), diff --git a/src/AliasVault.Admin/Services/UserService.cs b/src/AliasVault.Admin/Services/UserService.cs index 491f41341..da0760916 100644 --- a/src/AliasVault.Admin/Services/UserService.cs +++ b/src/AliasVault.Admin/Services/UserService.cs @@ -20,7 +20,6 @@ using Microsoft.EntityFrameworkCore; /// HttpContextManager instance. public class UserService(IAliasServerDbContextFactory dbContextFactory, UserManager userManager, IHttpContextAccessor httpContextAccessor) { - private const string AdminRole = "Admin"; private AdminUser? _user; /// @@ -28,11 +27,6 @@ public class UserService(IAliasServerDbContextFactory dbContextFactory, UserMana /// public event Action OnChange = () => { }; - /// - /// Gets a value indicating whether the User is loaded and available, false if not. Use this before accessing User() method. - /// - public bool UserLoaded => _user != null; - /// /// Returns all users. /// @@ -85,7 +79,7 @@ public class UserService(IAliasServerDbContextFactory dbContextFactory, UserMana // Load user from database. Use a new context everytime to ensure we get the latest data. var userName = httpContextAccessor.HttpContext?.User.Identity?.Name ?? string.Empty; - var dbContext = await dbContextFactory.CreateDbContextAsync(); + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); var user = await dbContext.AdminUsers.FirstOrDefaultAsync(u => u.UserName == userName); if (user != null) { diff --git a/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationAdminFactoryFixture.cs b/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationAdminFactoryFixture.cs index 27f723b29..9300c14fb 100644 --- a/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationAdminFactoryFixture.cs +++ b/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationAdminFactoryFixture.cs @@ -15,8 +15,10 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Npgsql; /// /// Admin web application factory fixture for integration tests. @@ -25,15 +27,10 @@ using Microsoft.Extensions.Hosting; public class WebApplicationAdminFactoryFixture : WebApplicationFactory where TEntryPoint : class { - /// - /// The DbConnection instance that is created for the test. - /// - private DbConnection _dbConnection; - /// /// The DbContextFactory instance that is created for the test. /// - private IDbContextFactory _dbContextFactory = null!; + private IAliasServerDbContextFactory _dbContextFactory = null!; /// /// The cached DbContext instance that can be used during the test. @@ -41,13 +38,9 @@ public class WebApplicationAdminFactoryFixture : WebApplicationFact private AliasServerDbContext? _dbContext; /// - /// Initializes a new instance of the class. + /// The name of the temporary test database. /// - public WebApplicationAdminFactoryFixture() - { - _dbConnection = new SqliteConnection("DataSource=:memory:"); - _dbConnection.Open(); - } + private string? _tempDbName; /// /// Gets or sets the port the web application kestrel host will listen on. @@ -70,14 +63,46 @@ public class WebApplicationAdminFactoryFixture : WebApplicationFact } /// - /// Disposes the DbConnection instance. + /// Disposes the DbConnection instance and drops the temporary database. /// - /// ValueTask. - public override ValueTask DisposeAsync() + /// Task. + public override async ValueTask DisposeAsync() { - _dbConnection.Dispose(); + if (_dbContext != null) + { + await _dbContext.DisposeAsync(); + _dbContext = null; + } + + if (!string.IsNullOrEmpty(_tempDbName)) + { + // Create a connection to 'postgres' database to drop the test database + using var conn = new NpgsqlConnection("Host=localhost;Port=5432;Database=postgres;Username=aliasvault;Password=password"); + await conn.OpenAsync(); + + // First terminate existing connections + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $""" + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = '{_tempDbName}'; + """; + await cmd.ExecuteNonQueryAsync(); + } + + // Then drop the database in a separate command + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $""" + DROP DATABASE IF EXISTS "{_tempDbName}"; + """; + await cmd.ExecuteNonQueryAsync(); + } + } + GC.SuppressFinalize(this); - return base.DisposeAsync(); + await base.DisposeAsync(); } /// @@ -92,7 +117,7 @@ public class WebApplicationAdminFactoryFixture : WebApplicationFact var host = base.CreateHost(builder); // Get the DbContextFactory instance and store it for later use during tests. - _dbContextFactory = host.Services.GetRequiredService>(); + _dbContextFactory = host.Services.GetRequiredService(); return host; } @@ -102,6 +127,20 @@ public class WebApplicationAdminFactoryFixture : WebApplicationFact { SetEnvironmentVariables(); + builder.ConfigureAppConfiguration((context, configBuilder) => + { + configBuilder.Sources.Clear(); + + _tempDbName = $"aliasdb_test_{Guid.NewGuid()}"; + + configBuilder.AddJsonFile("appsettings.json", optional: true); + configBuilder.AddInMemoryCollection(new Dictionary + { + ["DatabaseProvider"] = "postgresql", + ["ConnectionStrings:AliasServerDbContext"] = $"Host=localhost;Port=5432;Database={_tempDbName};Username=aliasvault;Password=password", + }); + }); + builder.ConfigureServices(services => { RemoveExistingRegistrations(services); @@ -126,7 +165,6 @@ public class WebApplicationAdminFactoryFixture : WebApplicationFact private static void RemoveExistingRegistrations(IServiceCollection services) { var descriptorsToRemove = services.Where(d => - d.ServiceType.ToString().Contains("AliasServerDbContext") || d.ServiceType == typeof(VersionedContentService)).ToList(); foreach (var descriptor in descriptorsToRemove) @@ -142,10 +180,10 @@ public class WebApplicationAdminFactoryFixture : WebApplicationFact private void AddNewRegistrations(IServiceCollection services) { // Add the DbContextFactory - services.AddDbContextFactory(options => + /*services.AddDbContextFactory(options => { options.UseSqlite(_dbConnection).UseLazyLoadingProxies(); - }); + });*/ // Add the VersionedContentService services.AddSingleton(new VersionedContentService("../../../../../AliasVault.Admin/wwwroot")); diff --git a/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationApiFactoryFixture.cs b/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationApiFactoryFixture.cs index b7103104b..33f1962b8 100644 --- a/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationApiFactoryFixture.cs +++ b/src/Tests/AliasVault.E2ETests/Infrastructure/WebApplicationApiFactoryFixture.cs @@ -15,8 +15,10 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Npgsql; /// /// API web application factory fixture for integration tests. @@ -25,15 +27,10 @@ using Microsoft.Extensions.Hosting; public class WebApplicationApiFactoryFixture : WebApplicationFactory where TEntryPoint : class { - /// - /// The DbConnection instance that is created for the test. - /// - private DbConnection _dbConnection; - /// /// The DbContextFactory instance that is created for the test. /// - private IDbContextFactory _dbContextFactory = null!; + private IAliasServerDbContextFactory _dbContextFactory = null!; /// /// The cached DbContext instance that can be used during the test. @@ -41,13 +38,9 @@ public class WebApplicationApiFactoryFixture : WebApplicationFactor private AliasServerDbContext? _dbContext; /// - /// Initializes a new instance of the class. + /// The name of the temporary test database. /// - public WebApplicationApiFactoryFixture() - { - _dbConnection = new SqliteConnection("DataSource=:memory:"); - _dbConnection.Open(); - } + private string? _tempDbName; /// /// Gets or sets the port the web application kestrel host will listen on. @@ -75,14 +68,46 @@ public class WebApplicationApiFactoryFixture : WebApplicationFactor } /// - /// Disposes the DbConnection instance. + /// Disposes the DbConnection instance and drops the temporary database. /// - /// ValueTask. - public override ValueTask DisposeAsync() + /// Task. + public override async ValueTask DisposeAsync() { - _dbConnection.Dispose(); + if (_dbContext != null) + { + await _dbContext.DisposeAsync(); + _dbContext = null; + } + + if (!string.IsNullOrEmpty(_tempDbName)) + { + // Create a connection to 'postgres' database to drop the test database + using var conn = new NpgsqlConnection("Host=localhost;Port=5432;Database=postgres;Username=aliasvault;Password=password"); + await conn.OpenAsync(); + + // First terminate existing connections + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $""" + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = '{_tempDbName}'; + """; + await cmd.ExecuteNonQueryAsync(); + } + + // Then drop the database in a separate command + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $""" + DROP DATABASE IF EXISTS "{_tempDbName}"; + """; + await cmd.ExecuteNonQueryAsync(); + } + } + GC.SuppressFinalize(this); - return base.DisposeAsync(); + await base.DisposeAsync(); } /// @@ -97,7 +122,7 @@ public class WebApplicationApiFactoryFixture : WebApplicationFactor var host = base.CreateHost(builder); // Get the DbContextFactory instance and store it for later use during tests. - _dbContextFactory = host.Services.GetRequiredService>(); + _dbContextFactory = host.Services.GetRequiredService(); return host; } @@ -107,6 +132,20 @@ public class WebApplicationApiFactoryFixture : WebApplicationFactor { SetEnvironmentVariables(); + builder.ConfigureAppConfiguration((context, configBuilder) => + { + configBuilder.Sources.Clear(); + + _tempDbName = $"aliasdb_test_{Guid.NewGuid()}"; + + configBuilder.AddJsonFile("appsettings.json", optional: true); + configBuilder.AddInMemoryCollection(new Dictionary + { + ["DatabaseProvider"] = "postgresql", + ["ConnectionStrings:AliasServerDbContext"] = $"Host=localhost;Port=5432;Database={_tempDbName};Username=aliasvault;Password=password", + }); + }); + builder.ConfigureServices(services => { RemoveExistingRegistrations(services); @@ -131,7 +170,6 @@ public class WebApplicationApiFactoryFixture : WebApplicationFactor private static void RemoveExistingRegistrations(IServiceCollection services) { var descriptorsToRemove = services.Where(d => - d.ServiceType.ToString().Contains("AliasServerDbContext") || d.ServiceType == typeof(ITimeProvider)).ToList(); foreach (var descriptor in descriptorsToRemove) @@ -147,10 +185,11 @@ public class WebApplicationApiFactoryFixture : WebApplicationFactor private void AddNewRegistrations(IServiceCollection services) { // Add the DbContextFactory - services.AddDbContextFactory(options => + /*services.AddDbContextFactory(options => { options.UseSqlite(_dbConnection).UseLazyLoadingProxies(); - }); + });*/ + // services.AddSingleton(); // Add TestTimeProvider services.AddSingleton(TimeProvider); diff --git a/src/Tests/AliasVault.E2ETests/Tests/Admin/ServerSettingsTests.cs b/src/Tests/AliasVault.E2ETests/Tests/Admin/ServerSettingsTests.cs index 1867d0ae0..7ab8fa3b4 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/Admin/ServerSettingsTests.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/Admin/ServerSettingsTests.cs @@ -38,8 +38,8 @@ public class ServerSettingsTests : AdminPlaywrightTest await Page.Locator("input[id='schedule']").FillAsync("03:30"); // Uncheck Sunday and Saturday from maintenance days - await Page.Locator("input[id='day_7']").UncheckAsync(); // Sunday await Page.Locator("input[id='day_6']").UncheckAsync(); // Saturday + await Page.Locator("input[id='day_7']").UncheckAsync(); // Sunday // Save changes var saveButton = Page.Locator("text=Save changes"); @@ -75,6 +75,9 @@ public class ServerSettingsTests : AdminPlaywrightTest await Page.ReloadAsync(); await WaitForUrlAsync("settings/server", "Server settings"); + // Wait for 0.5sec to ensure the page is fully loaded. + await Task.Delay(500); + var generalLogRetentionValue = await Page.Locator("input[id='generalLogRetention']").InputValueAsync(); Assert.That(generalLogRetentionValue, Is.EqualTo("45"), "General log retention value not persisted after refresh"); diff --git a/src/Tests/AliasVault.E2ETests/Tests/Admin/TwoFactorAuthLockoutTests.cs b/src/Tests/AliasVault.E2ETests/Tests/Admin/TwoFactorAuthLockoutTests.cs index a8d488e0a..5c8a6af17 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/Admin/TwoFactorAuthLockoutTests.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/Admin/TwoFactorAuthLockoutTests.cs @@ -29,6 +29,9 @@ public class TwoFactorAuthLockoutTests : AdminPlaywrightTest var enable2FaButton = Page.GetByRole(AriaRole.Link, new() { Name = "Add authenticator app" }); await enable2FaButton.ClickAsync(); + // Wait for QR code to appear. + await WaitForUrlAsync("account/manage/enable-authenticator", "Scan the QR Code or enter this key"); + // Extract secret key from page. var secretKey = await Page.TextContentAsync("kbd");