Add E2E test for browser extension credential create flow (#643)

This commit is contained in:
Leendert de Borst
2025-03-05 16:37:38 +01:00
committed by Leendert de Borst
parent 10f6525e94
commit b415043b4e
6 changed files with 267 additions and 125 deletions

View File

@@ -866,6 +866,7 @@ export async function createEditNamePopup(defaultName: string): Promise<string |
return new Promise((resolve) => {
// Create modal overlay
const overlay = document.createElement('div');
overlay.id = 'aliasvault-create-popup';
overlay.style.cssText = `
position: fixed;
top: 0;

View File

@@ -30,6 +30,7 @@ describe('AppInfo', () => {
it('should reject lower versions', () => {
expect(AppInfo.versionGreaterThanOrEqualTo('1.0.0', '1.0.1')).toBe(false);
expect(AppInfo.versionGreaterThanOrEqualTo('1.0.0', '1.4.1')).toBe(false);
expect(AppInfo.versionGreaterThanOrEqualTo('1.4.0', '1.5.0')).toBe(false);
expect(AppInfo.versionGreaterThanOrEqualTo('1.9.9', '2.0.0')).toBe(false);
});

View File

@@ -11,7 +11,8 @@ Follow the steps in the checklist below to prepare a new release.
## Versioning client and server
- [ ] Update ./src/Shared/AliasVault.Shared.Core/AppInfo.cs and update major/minor/patch to the new version. This version will be shown in the client and admin app footer. This version should be equal to the git release tag.
- [ ] Update ./src/Shared/AliasVault.Shared.Core/AppInfo.cs with the minimum supported client versions (in case API output breaks earlier client versions).
- [ ] Update ./src/Shared/AliasVault.Shared.Core/AppInfo.cs with the minimum supported client versions.
- In case API output breaks earlier client versions and/or this version of the client/API will upgrade the client vault model to a new major version.
- [ ] Update ./install.sh `@version` in header if the install script has changed. This allows the install script to self-update when running the `./install.sh update` command on default installations.
- [ ] Update README.md install.sh download link to point to the new release version

View File

@@ -5,7 +5,7 @@
<SmallLoadingIndicator Title="@LoadingIndicatorMessage" Spinning="@Loading">
@if (!Loading)
{
<button class="absolute p-2 hover:bg-gray-200 rounded-2xl" @onclick="OnRefreshClick">
<button class="absolute p-2 hover:bg-gray-200 rounded-2xl" id="vault-refresh-btn" @onclick="OnRefreshClick">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
</svg>

View File

@@ -0,0 +1,175 @@
//-----------------------------------------------------------------------
// <copyright file="BrowserExtensionPlaywrightTest.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.E2ETests.Common;
using AliasVault.E2ETests.Tests.Extensions;
using Microsoft.Playwright;
/// <summary>
/// Base class for tests that use Playwright for E2E browser testing and test functionality in the browser extension.
/// </summary>
public class BrowserExtensionPlaywrightTest : ClientPlaywrightTest
{
private string _extensionPath = string.Empty;
/// <summary>
/// Set up the Playwright browser and context based on settings defined in appsettings.json.
/// </summary>
/// <returns>Task.</returns>
protected override async Task SetupPlaywrightBrowserAndContext()
{
// Make sure the extension is built and ready to use.
ExtensionSetup();
var playwright = await Playwright.CreateAsync();
// Launch persistent context with the extension loaded
Context = await playwright.Chromium.LaunchPersistentContextAsync(
userDataDir: string.Empty, // Empty string means temporary directory
new BrowserTypeLaunchPersistentContextOptions
{
Headless = false,
Args = new[]
{
"--disable-extensions-except=" + _extensionPath,
"--load-extension=" + _extensionPath,
},
ServiceWorkers = ServiceWorkerPolicy.Allow,
});
}
/// <summary>
/// Open the extension popup, configure the API URL and login with the test credentials.
/// If already logged in, returns the existing popup page.
/// </summary>
/// <param name="waitForLogin">If true, wait for the login to complete. Set to false for testing login errors.</param>
/// <returns>Task.</returns>
protected async Task<IPage> LoginToExtension(bool waitForLogin = true)
{
// Use reflection to access the ServiceWorkers property
List<object> serviceWorkers;
try
{
var serviceWorkersProperty = Context.GetType().GetProperty("ServiceWorkers");
var serviceWorkersEnumerable = serviceWorkersProperty?.GetValue(Context) as IEnumerable<object>;
if (serviceWorkersEnumerable == null)
{
throw new InvalidOperationException("Could not find extension service workers");
}
serviceWorkers = serviceWorkersEnumerable.ToList();
if (serviceWorkers.Count == 0)
{
throw new InvalidOperationException("No extension service workers found");
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to get service workers, check if the extension is loaded properly: {ex.Message}");
throw;
}
// Get the first service worker's URL using reflection
var firstWorker = serviceWorkers[0];
var urlProperty = firstWorker.GetType().GetProperty("Url");
var url = urlProperty?.GetValue(firstWorker) as string;
var extensionId = url?.Split('/')[2]
?? throw new InvalidOperationException("Could not find extension service worker URL");
// Open popup in a new page
var extensionPopup = await Context.NewPageAsync();
await extensionPopup.GotoAsync($"chrome-extension://{extensionId}/index.html");
// Check if already logged in by looking for elements that only appear on the logged-in view
try
{
// Try to find an element that's only visible when logged in (like the settings button)
// with a short timeout
await extensionPopup.WaitForSelectorAsync("text=Credentials", new() { Timeout = 2000 });
// If we get here, we're already logged in
return extensionPopup;
}
catch
{
// If the selector wasn't found, proceed with login
}
// Configure API URL in settings first
await extensionPopup.ClickAsync("button[id='settings']");
// Select "Self-hosted" option first
await extensionPopup.SelectOptionAsync("select", ["custom"]);
// Fill in the custom URL input that appears
await extensionPopup.FillAsync("input[id='custom-api-url']", ApiBaseUrl);
// Go back to main page
await extensionPopup.ClickAsync("button[id='back']");
// Test vault loading with username and password
await extensionPopup.FillAsync("input[type='text']", TestUserUsername);
await extensionPopup.FillAsync("input[type='password']", TestUserPassword);
await extensionPopup.ClickAsync("button:has-text('Login')");
// Wait for login to complete by waiting for expected text.
if (waitForLogin)
{
await extensionPopup.WaitForSelectorAsync("text=Credentials");
}
return extensionPopup;
}
/// <summary>
/// Find the solution root directory by walking up from the current assembly location.
/// </summary>
/// <param name="startPath">The starting directory.</param>
/// <returns>The solution root directory.</returns>
private static string FindSolutionRoot(string startPath)
{
var directory = new DirectoryInfo(startPath);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "AliasVault.sln")))
{
directory = directory.Parent;
}
if (directory == null)
{
throw new DirectoryNotFoundException("Could not find solution root directory");
}
return directory.FullName;
}
/// <summary>
/// Sets up the extension by running npm install and build.
/// </summary>
private void ExtensionSetup()
{
// Get the solution directory by walking up from the current assembly location
var currentDir = Path.GetDirectoryName(typeof(ChromeExtensionTests).Assembly.Location)
?? throw new InvalidOperationException("Current directory not found");
var solutionDir = FindSolutionRoot(currentDir);
// Construct absolute path to extension directory
var extensionDir = Path.GetFullPath(Path.Combine(solutionDir, "browser-extensions", "chrome"));
var distDir = Path.GetFullPath(Path.Combine(extensionDir, "dist"));
var manifestPath = Path.Combine(distDir, "manifest.json");
// Verify the dist directory exists and contains required files
if (!Directory.Exists(distDir) || !File.Exists(manifestPath))
{
throw new ArgumentException($"Chrome extension dist directory and/or manifest.json not found at {distDir}. Please run 'npm install && npm run build' in {extensionDir}.");
}
_extensionPath = distDir.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
}
}

View File

@@ -7,21 +7,21 @@
namespace AliasVault.E2ETests.Tests.Extensions;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// End-to-end tests for the Chrome extension. We extend from ClientPlaywrightTest as extension tess requires
/// mutating things via the client too to test all extension functionality properly such as syncing vaults.
/// End-to-end tests for the Chrome extension.
/// </summary>
[Parallelizable(ParallelScope.Self)]
[Category("ExtensionTests")]
[TestFixture]
public class ChromeExtensionTests : ClientPlaywrightTest
public class ChromeExtensionTests : BrowserExtensionPlaywrightTest
{
private string _extensionPath = string.Empty;
/// <summary>
/// Tests if the extension can load a vault and a previously created credential entry is present.
/// </summary>
/// <returns>Async task.</returns>
[Order(1)]
[Test]
public async Task ExtensionCredentialExists()
{
@@ -41,135 +41,99 @@ public class ChromeExtensionTests : ClientPlaywrightTest
}
/// <summary>
/// Set up the Playwright browser and context based on settings defined in appsettings.json.
/// Tests the extension's ability to create a new credential.
/// </summary>
/// <returns>Task.</returns>
protected override async Task SetupPlaywrightBrowserAndContext()
/// <returns>Async task.</returns>
[Order(2)]
[Test]
public async Task ExtensionCreateCredentialTest()
{
// Make sure the extension is built and ready to use.
ExtensionSetup();
var emailClaimsCountInitial = await ApiDbContext.UserEmailClaims.CountAsync();
var playwright = await Playwright.CreateAsync();
// Login to the extension
var extensionPopup = await LoginToExtension();
// Launch persistent context with the extension loaded
Context = await playwright.Chromium.LaunchPersistentContextAsync(
userDataDir: string.Empty, // Empty string means temporary directory
new BrowserTypeLaunchPersistentContextOptions
{
Headless = false,
Args = new[]
{
"--disable-extensions-except=" + _extensionPath,
"--load-extension=" + _extensionPath,
},
ServiceWorkers = ServiceWorkerPolicy.Allow,
});
}
// Create a temporary HTML file with the test form
var tempHtmlPath = Path.Combine(Path.GetTempPath(), "test-form.html");
var testFormHtml = @"
<html>
<head>
<title>Login</title>
</head>
<body>
<h1>AliasVault browser extension form test</h1>
<form>
<input type='text' id='username' placeholder='Username'>
<input type='password' id='password' placeholder='Password'>
<button type='submit'>Login</button>
</form>
</body>
</html>
";
/// <summary>
/// Find the solution root directory by walking up from the current assembly location.
/// </summary>
/// <param name="startPath">The starting directory.</param>
/// <returns>The solution root directory.</returns>
private static string FindSolutionRoot(string startPath)
{
var directory = new DirectoryInfo(startPath);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "AliasVault.sln")))
await File.WriteAllTextAsync(tempHtmlPath, testFormHtml);
// Navigate to the file using the file:// protocol
await extensionPopup.GotoAsync($"file://{tempHtmlPath}");
// Focus the username field which should trigger the AliasVault popup
await extensionPopup.FocusAsync("input#username");
// Wait for the AliasVault popup to appear
await extensionPopup.WaitForSelectorAsync("#aliasvault-credential-popup");
// Click the "New" button in the popup
await extensionPopup.ClickAsync("button:has-text('New')");
// Set the service name for the new credential
var serviceName = "Test Service Extension";
await extensionPopup.FillAsync("input[id='service-name-input']", serviceName);
// Click the "Create" button
await extensionPopup.ClickAsync("button[id='save-btn']");
// Wait for the "aliasvault-create-popup" to disappear
await extensionPopup.WaitForSelectorAsync("#aliasvault-create-popup", new() { State = WaitForSelectorState.Hidden });
// Wait for the credential to be created and the form fields to be filled with values
await extensionPopup.WaitForFunctionAsync(
@"() => {
const username = document.querySelector('input#username');
const password = document.querySelector('input#password');
return username?.value && password?.value;
}",
null,
new() { Timeout = 10000 });
// Verify the form fields were filled
var username = await extensionPopup.InputValueAsync("input#username");
var password = await extensionPopup.InputValueAsync("input#password");
Assert.Multiple(() =>
{
directory = directory.Parent;
}
Assert.That(username, Is.Not.Empty, "Username field was not filled");
Assert.That(password, Is.Not.Empty, "Password field was not filled");
});
if (directory == null)
{
throw new DirectoryNotFoundException("Could not find solution root directory");
}
// Now verify the credential appears in the client app
await Page.BringToFrontAsync();
return directory.FullName;
}
// Refresh the vault via the refresh button to get the latest vault that browser extension just uploaded
await Page.ClickAsync("button[id='vault-refresh-btn']");
/// <summary>
/// Sets up the extension by running npm install and build.
/// </summary>
private void ExtensionSetup()
{
// Get the solution directory by walking up from the current assembly location
var currentDir = Path.GetDirectoryName(typeof(ChromeExtensionTests).Assembly.Location)
?? throw new InvalidOperationException("Current directory not found");
var solutionDir = FindSolutionRoot(currentDir);
// Navigate to the credentials page explicitly in case we were stuck on the welcome screen.
await Page.ClickAsync("a[href='/credentials']");
// Construct absolute path to extension directory
var extensionDir = Path.GetFullPath(Path.Combine(solutionDir, "browser-extensions", "chrome"));
var distDir = Path.GetFullPath(Path.Combine(extensionDir, "dist"));
var manifestPath = Path.Combine(distDir, "manifest.json");
// Wait for credentials page to load and verify the new credential appears
await Page.WaitForSelectorAsync("text=" + serviceName);
var pageContent = await Page.TextContentAsync("body");
Assert.That(pageContent, Does.Contain(serviceName), "Created credential service name does not appear in client app");
// Verify the dist directory exists and contains required files
if (!Directory.Exists(distDir) || !File.Exists(manifestPath))
{
throw new ArgumentException($"Chrome extension dist directory and/or manifest.json not found at {distDir}. Please run 'npm install && npm run build' in {extensionDir}.");
}
// Assert that email claims is now at one to verify that the email claim was correctly passed to the API from
// the browser extension.
var emailClaimsCount = await ApiDbContext.UserEmailClaims.CountAsync();
Assert.That(emailClaimsCount, Is.EqualTo(emailClaimsCountInitial + 1), "Email claim for user not at expected count. Check browser extension and API email claim register logic.");
_extensionPath = distDir.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
}
/// <summary>
/// Open the extension popup, configure the API URL and login with the test credentials.
/// </summary>
/// <returns>Task.</returns>
private async Task<IPage> LoginToExtension()
{
// Use reflection to access the ServiceWorkers property
List<object> serviceWorkers;
try
{
var serviceWorkersProperty = Context.GetType().GetProperty("ServiceWorkers");
var serviceWorkersEnumerable = serviceWorkersProperty?.GetValue(Context) as IEnumerable<object>;
if (serviceWorkersEnumerable == null)
{
throw new InvalidOperationException("Could not find extension service workers");
}
serviceWorkers = serviceWorkersEnumerable.ToList();
if (serviceWorkers.Count == 0)
{
throw new InvalidOperationException("No extension service workers found");
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to get service workers, check if the extension is loaded properly: {ex.Message}");
throw;
}
// Get the first service worker's URL using reflection
var firstWorker = serviceWorkers[0];
var urlProperty = firstWorker.GetType().GetProperty("Url");
var url = urlProperty?.GetValue(firstWorker) as string;
var extensionId = url?.Split('/')[2]
?? throw new InvalidOperationException("Could not find extension service worker URL");
// Open popup in a new page
var extensionPopup = await Context.NewPageAsync();
await extensionPopup.GotoAsync($"chrome-extension://{extensionId}/index.html");
// Configure API URL in settings first
await extensionPopup.ClickAsync("button[id='settings']");
// Select "Self-hosted" option first
await extensionPopup.SelectOptionAsync("select", ["custom"]);
// Fill in the custom URL input that appears
await extensionPopup.FillAsync("input[id='custom-api-url']", ApiBaseUrl);
// Go back to main page
await extensionPopup.ClickAsync("button[id='back']");
// Test vault loading with username and password
await extensionPopup.FillAsync("input[type='text']", TestUserUsername);
await extensionPopup.FillAsync("input[type='password']", TestUserPassword);
await extensionPopup.ClickAsync("button:has-text('Login')");
return extensionPopup;
// Clean up the temporary file after the test
File.Delete(tempHtmlPath);
}
}