mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-12 01:13:30 -04:00
Add E2E test for browser extension credential create flow (#643)
This commit is contained in:
committed by
Leendert de Borst
parent
10f6525e94
commit
b415043b4e
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user