diff --git a/.dockerignore b/.dockerignore index c7ffe132cf3..7e255aaf513 100644 --- a/.dockerignore +++ b/.dockerignore @@ -38,3 +38,6 @@ web-vault # Vaultwarden Resources resources + +# Playwright tests +playwright diff --git a/playwright/.gitignore b/playwright/.gitignore new file mode 100644 index 00000000000..8746d597aa0 --- /dev/null +++ b/playwright/.gitignore @@ -0,0 +1,6 @@ +logs +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ +temp diff --git a/playwright/README.md b/playwright/README.md new file mode 100644 index 00000000000..a35654a038d --- /dev/null +++ b/playwright/README.md @@ -0,0 +1,63 @@ +# Integration tests + +This allows running integration tests using [Playwright](https://playwright.dev/). +\ +It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance. + +## Install + +```bash +npm install +npx playwright install firefox +``` + +## Usage + +To run all the tests: + +```bash +npx playwright test +``` + +To access the ui to easily run test individually and debug if needed: + +```bash +npx playwright test --ui +``` + +### DB + +Projects are configured to allow to run tests only on specific database. +\ +You can use: + +```bash +npx playwright test --project sqllite +npx playwright test --project postgres +npx playwright test --project mysql +``` + +### Running specific tests + +To run a whole file you can : + +```bash +npx playwright test --project=sqllite tests/login.spec.ts +npx playwright test --project=sqllite login +``` + +To run only a specifc test (It might fail if it has dependency): + +```bash +npx playwright test --project=sqllite -g "Account creation" +npx playwright test --project=sqllite tests/login.spec.ts:16 +``` + +## Writing scenario + +When creating new scenario use the recorder to more easily identify elements (in general try to rely on visible hint to identify elements and not hidden ids). +This does not start the server, you will need to start it manually. + +```bash +npx playwright codegen "http://127.0.0.1:8000" +``` diff --git a/playwright/global-setup.ts b/playwright/global-setup.ts new file mode 100644 index 00000000000..7f99a2fd985 --- /dev/null +++ b/playwright/global-setup.ts @@ -0,0 +1,79 @@ +import { firefox, type FullConfig } from '@playwright/test'; +import { exec, execSync } from 'node:child_process'; +import fs from 'fs'; +import yaml from 'js-yaml'; + +const utils = require('./global-utils'); + +utils.loadEnv(); + +function readCurrentVersion(){ + try { + const vw_version_file = fs.readFileSync('temp/web-vault/vw-version.json', { + encoding: 'utf8', + flag: 'r' + }); + + return JSON.parse(vw_version_file)["version"]; + } catch(err) { + console.log(`Failed to read frontend current version: ${err}`); + } +} + +function readDockerVersion(){ + try { + const docker_settings = fs.readFileSync('../docker/DockerSettings.yaml', { + encoding: 'utf8', + flag: 'r' + }); + + const settings = yaml.load(docker_settings); + return settings["vault_version"]; + } catch(err) { + console.log(`Failed to read docker frontend current version: ${err}`); + } +} + +function retrieveFrontend(){ + const vw_version = readCurrentVersion(); + const vv = readDockerVersion() + + if( !vv ){ + console.log("Empty docker frontend version"); + process.exit(1); + } + + try { + if( vv != `v${vw_version}`) { + fs.rmSync("./temp/web-vault", { recursive: true, force: true }); + + execSync(`cd temp && wget -c https://github.com/dani-garcia/bw_web_builds/releases/download/${vv}/bw_web_${vv}.tar.gz -O - | tar xz`, { stdio: "inherit" }); + + console.log(`Retrieved bw_web_builds-${vv}`); + } else { + console.log(`Using existing bw_web_builds-${vv}`); + } + } catch(err) { + console.log(`Failed to retrieve frontend: ${err}`); + process.exit(1); + } +} + +function buildServer(){ + if( !fs.existsSync('temp/vaultwarden') ){ + console.log("Rebuilding server"); + execSync(`cd .. && cargo build --features sqlite,mysql,postgresql --release`, { stdio: "inherit" }); + execSync(`cp ../target/release/vaultwarden temp/vaultwarden`, { stdio: "inherit" }); + } else { + console.log("Using existing server"); + } +} + +async function globalSetup(config: FullConfig) { + execSync("mkdir -p temp/logs"); + + buildServer(); + retrieveFrontend(); +} + +export default globalSetup; diff --git a/playwright/global-utils.ts b/playwright/global-utils.ts new file mode 100644 index 00000000000..520e89046e3 --- /dev/null +++ b/playwright/global-utils.ts @@ -0,0 +1,139 @@ +import { type Browser, type TestInfo } from '@playwright/test'; +import { execSync } from 'node:child_process'; +import dotenv from 'dotenv'; +import dotenvExpand from 'dotenv-expand'; + +const fs = require("fs"); +const { spawn } = require('node:child_process'); + +function loadEnv(){ + var myEnv = dotenv.config({ path: 'test.env' }); + dotenvExpand.expand(myEnv); +} + +async function waitFor(url: String, browser: Browser) { + var ready = false; + var context; + + do { + try { + context = await browser.newContext(); + const page = await context.newPage(); + await page.waitForTimeout(500); + const result = await page.goto(url); + ready = result.status() === 200; + } catch(e) { + if( !e.message.includes("CONNECTION_REFUSED") ){ + throw e; + } + } finally { + await context.close(); + } + } while(!ready); +} + +function startStopSqlite(){ + fs.rmSync("temp/db.sqlite3", { force: true }); + fs.rmSync("temp/db.sqlite3-shm", { force: true }); + fs.rmSync("temp/db.sqlite3-wal", { force: true }); +} + +function startMariaDB() { + console.log(`Starting MariaDB`); + execSync(`docker run --rm --name ${process.env.MARIADB_CONTAINER} \ + -e MARIADB_ROOT_PASSWORD=${process.env.MARIADB_PWD} \ + -e MARIADB_USER=${process.env.MARIADB_USER} \ + -e MARIADB_PASSWORD=${process.env.MARIADB_PWD} \ + -e MARIADB_DATABASE=${process.env.MARIADB_DB} \ + -p ${process.env.MARIADB_PORT}:3306 \ + -d mariadb:10.4` + ); +} + + +function stopMariaDB() { + console.log("Stopping MariaDB (ensure DB is wiped)"); + execSync(`docker stop ${process.env.MARIADB_CONTAINER} || true`); +} + +function startPostgres() { + console.log(`Starting Postgres`); + execSync(`docker run --rm --name ${process.env.POSTGRES_CONTAINER} \ + -e POSTGRES_USER=${process.env.POSTGRES_USER} \ + -e POSTGRES_PASSWORD=${process.env.POSTGRES_PWD} \ + -e POSTGRES_DB=${process.env.POSTGRES_DB} \ + -p ${process.env.POSTGRES_PORT}:5432 \ + -d postgres:16.2` + ); +}; + +function stopPostgres() { + console.log("Stopping Postgres (Ensure DB is wiped)"); + execSync(`docker stop ${process.env.POSTGRES_CONTAINER} || true`); +} + +function dbConfig(testInfo: TestInfo){ + switch(testInfo.project.name) { + case "postgres": return { + DATABASE_URL: `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PWD}@127.0.0.1:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}` + } + case "mysql": return { + DATABASE_URL: `mysql://${process.env.MARIADB_USER}:${process.env.MARIADB_PWD}@127.0.0.1:${process.env.MARIADB_PORT}/${process.env.MARIADB_DB}` + } + default: return { I_REALLY_WANT_VOLATILE_STORAGE: true } + } +} + +async function startVaultwarden(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) { + if( resetDB ){ + switch(testInfo.project.name) { + case "postgres": + stopPostgres(); + startPostgres() + break; + case "mysql": + stopMariaDB(); + startMariaDB(); + break; + default: + startStopSqlite(); + } + } + + const vw_log = fs.openSync("temp/logs/vaultwarden.log", "a"); + var proc = spawn("temp/vaultwarden", { + env: { ...process.env, ...env, ...dbConfig(testInfo) }, + stdio: [process.stdin, vw_log, vw_log] + }); + + await waitFor("/", browser); + + console.log(`Vaultwarden running on: ${process.env.DOMAIN}`); + + return proc; +} + +async function stopVaultwarden(proc, testInfo: TestInfo, resetDB: Boolean = true) { + console.log(`Vaultwarden stopping`); + proc.kill(); + + if( resetDB ){ + switch(testInfo.project.name) { + case "postgres": + stopPostgres(); + break; + case "mysql": + stopMariaDB(); + break; + default: + startStopSqlite(); + } + } +} + +async function restartVaultwarden(proc, page: Page, testInfo: TestInfo, env, resetDB: Boolean = true) { + stopVaultwarden(proc, testInfo, resetDB); + return startVaultwarden(page.context().browser(), testInfo, env, resetDB); +} + +export { loadEnv, waitFor, startVaultwarden, stopVaultwarden, restartVaultwarden }; diff --git a/playwright/package-lock.json b/playwright/package-lock.json new file mode 100644 index 00000000000..0b5fd2e9861 --- /dev/null +++ b/playwright/package-lock.json @@ -0,0 +1,155 @@ +{ + "name": "scenarios", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scenarios", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.41.2", + "@types/node": "^20.11.16", + "abort-controller": "3.0.0", + "dotenv": "^16.4.1", + "dotenv-expand": "^10.0.0", + "js-yaml": "4.1.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.41.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz", + "integrity": "sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==", + "dev": true, + "dependencies": { + "playwright": "1.41.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@types/node": { + "version": "20.11.16", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.16.tgz", + "integrity": "sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/dotenv": { + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz", + "integrity": "sha512-CjA3y+Dr3FyFDOAMnxZEGtnW9KBR2M0JvvUtXNW+dYJL5ROWxP9DUHCwgFqpMk0OXCc0ljhaNTr2w/kutYIcHQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/playwright": { + "version": "1.41.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.2.tgz", + "integrity": "sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==", + "dev": true, + "dependencies": { + "playwright-core": "1.41.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.41.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.2.tgz", + "integrity": "sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + } + } +} diff --git a/playwright/package.json b/playwright/package.json new file mode 100644 index 00000000000..2e8c8ffaf5d --- /dev/null +++ b/playwright/package.json @@ -0,0 +1,18 @@ +{ + "name": "scenarios", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.41.2", + "@types/node": "^20.11.16", + "abort-controller": "3.0.0", + "dotenv": "^16.4.1", + "dotenv-expand": "^10.0.0", + "js-yaml": "4.1.0" + } +} diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts new file mode 100644 index 00000000000..81b510b9285 --- /dev/null +++ b/playwright/playwright.config.ts @@ -0,0 +1,54 @@ +import { defineConfig, devices } from '@playwright/test'; +import { exec } from 'node:child_process'; + +const utils = require('./global-utils'); + +utils.loadEnv(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: 'tests', + /* Run tests in files in parallel */ + fullyParallel: false, + + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + workers: 1, + + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + timeout: 10 * 1000, + expect: { timeout: 10 * 1000 }, + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.DOMAIN, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'sqllite', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'postgres', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'mysql', + use: { ...devices['Desktop Firefox'] }, + }, + ], + + globalSetup: require.resolve('./global-setup'), +}); diff --git a/playwright/test.env b/playwright/test.env new file mode 100644 index 00000000000..950475e5e6e --- /dev/null +++ b/playwright/test.env @@ -0,0 +1,37 @@ +################################################################## +### Shared Playwright conf test file Vaultwarden and Databases ### +################################################################## + +############# +# Test user # +############# +TEST_USER=test +TEST_USER_PASSWORD=${TEST_USER} +TEST_USER_MAIL="${TEST_USER}@example.com" + +###################### +# Vaultwarden Config # +###################### +DATA_FOLDER=temp +WEB_VAULT_FOLDER=temp/web-vault/ + +ROCKET_PORT=8001 +DOMAIN=http://127.0.0.1:${ROCKET_PORT} + +########################### +# Docker MariaDb container# +########################### +MARIADB_CONTAINER=vw-mariadb-test +MARIADB_PORT=3307 +MARIADB_USER=vaultwarden +MARIADB_PWD=vaultwarden +MARIADB_DB=vaultwarden + +############################ +# Docker Postgres container# +############################ +POSTGRES_CONTAINER=vw-postgres-test +POSTGRES_PORT=5433 +POSTGRES_USER=vaultwarden +POSTGRES_PWD=vaultwarden +POSTGRES_DB=vaultwarden diff --git a/playwright/tests/login.spec.ts b/playwright/tests/login.spec.ts new file mode 100644 index 00000000000..ba301024812 --- /dev/null +++ b/playwright/tests/login.spec.ts @@ -0,0 +1,54 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +const utils = require('../global-utils'); + +utils.loadEnv(); + +var proc; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + proc = await utils.startVaultwarden(browser, testInfo, {}); +}); + +test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { + utils.stopVaultwarden(proc, testInfo); +}); + +test('Account creation', async ({ page }) => { + // Landing page + await page.goto('/'); + await page.getByRole('link', { name: 'Create account' }).click(); + + // Back to Vault create account + await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); + await page.getByLabel(/Email address/).fill(process.env.TEST_USER_MAIL); + await page.getByLabel('Name').fill(process.env.TEST_USER); + await page.getByLabel('Master password\n (required)', { exact: true }).fill('Master password'); + await page.getByLabel('Re-type master password').fill('Master password'); + await page.getByRole('button', { name: 'Create account' }).click(); + + // Back to the login page + await expect(page).toHaveTitle('Vaultwarden Web'); + await page.getByLabel('Your new account has been created') + await page.getByRole('button', { name: 'Continue' }).click(); + + // Unlock page + await page.getByLabel('Master password').fill('Master password'); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + // We are now in the default vault page + await expect(page).toHaveTitle(/Vaults/); +}); + +test('Master password login', async ({ page }) => { + // Landing page + await page.goto('/'); + await page.getByLabel(/Email address/).fill(process.env.TEST_USER_MAIL); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Unlock page + await page.getByLabel('Master password').fill('Master password'); + await page.getByRole('button', { name: 'Log in with master password' }).click(); + + // We are now in the default vault page + await expect(page).toHaveTitle(/Vaults/); +});