From d586c950b384614b42d8738d263e46f4abe0b1fd Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Mon, 12 May 2025 08:56:53 +0200 Subject: [PATCH] chore(e2e): Setup E2E testing for the UI --- .github/workflows/ui-end2end.yml | 78 +++++++++++++++ .gitignore | 8 ++ ui/package-lock.json | 63 ++++++++++++ ui/package.json | 1 + ui/playwright.config.ts | 85 +++++++++++++++++ ui/tests/e2e/auth/authentication.spec.ts | 116 +++++++++++++++++++++++ ui/tests/e2e/global-setup.ts | 20 ++++ 7 files changed, 371 insertions(+) create mode 100644 .github/workflows/ui-end2end.yml create mode 100644 ui/playwright.config.ts create mode 100644 ui/tests/e2e/auth/authentication.spec.ts create mode 100644 ui/tests/e2e/global-setup.ts diff --git a/.github/workflows/ui-end2end.yml b/.github/workflows/ui-end2end.yml new file mode 100644 index 0000000000..0bb0affe11 --- /dev/null +++ b/.github/workflows/ui-end2end.yml @@ -0,0 +1,78 @@ +name: UI - E2E Tests + +on: + pull_request: + branches: + - master + - "v5.*" + paths: + - 'ui/**' + +env: + SERVICES_TO_START: "api-dev postgres valkey worker-beat worker-dev" + DOCKER_COMPOSE_FILE: "docker-compose-dev.yml" + +jobs: + e2e: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + cache-dependency-path: './ui/package-lock.json' + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + playwright-${{ runner.os }}- + + - name: Install dependencies + run: npm ci + working-directory: ./ui + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + working-directory: ./ui + + - name: Start Docker Compose backend + run: docker-compose -f ${DOCKER_COMPOSE_FILE} up -d ${SERVICES_TO_START} + + - name: Wait for API to be ready + run: | + for i in {1..30}; do + if curl -s http://localhost:8000/; then + echo "API is up!" + break + fi + echo "Waiting for API..." + sleep 5 + done + + - name: Run Playwright tests + run: npx playwright test + working-directory: ./ui + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: ./ui/playwright-report + + - name: Upload Playwright videos + uses: actions/upload-artifact@v4 + with: + name: test-videos + path: ./ui/test-results/**/*.webm + + - name: Docker Compose Down + if: always() + run: docker-compose -f ${DOCKER_COMPOSE_FILE} down diff --git a/.gitignore b/.gitignore index d38969db40..70688bf1e9 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ junit-reports/ # Cursor files .cursorignore +.cursor/ # Terraform .terraform* @@ -65,3 +66,10 @@ node_modules # Persistent data _data/ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/ui/package-lock.json b/ui/package-lock.json index f0a11642d6..986931a94a 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@iconify/react": "^5.2.0", + "@playwright/test": "^1.52.0", "@types/bcryptjs": "^2.4.6", "@types/node": "20.5.7", "@types/react": "18.3.3", @@ -4403,6 +4404,22 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", @@ -10638,6 +10655,20 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -12859,6 +12890,38 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", diff --git a/ui/package.json b/ui/package.json index 25a1cdfb1a..3b128e8305 100644 --- a/ui/package.json +++ b/ui/package.json @@ -47,6 +47,7 @@ }, "devDependencies": { "@iconify/react": "^5.2.0", + "@playwright/test": "^1.52.0", "@types/bcryptjs": "^2.4.6", "@types/node": "20.5.7", "@types/react": "18.3.3", diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 0000000000..c8ffab04d5 --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,85 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests/e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* 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, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + globalSetup: require.resolve('./tests/e2e/global-setup'), + /* 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: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + // Capture screenshot on failure + screenshot: 'only-on-failure', + // Record video for failed tests + video: 'retain-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, // 2 minutes + }, +}); diff --git a/ui/tests/e2e/auth/authentication.spec.ts b/ui/tests/e2e/auth/authentication.spec.ts new file mode 100644 index 0000000000..7fbb17dc18 --- /dev/null +++ b/ui/tests/e2e/auth/authentication.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from '@playwright/test'; + +// Test credentials from environment variables +const TEST_USER_EMAIL = process.env.TEST_USER_EMAIL || 'dev@prowler.com'; +const TEST_USER_PASSWORD = process.env.TEST_USER_PASSWORD || 'thisisapassword123'; +const TEST_USER_NAME = process.env.TEST_USER_NAME || 'Prowler User'; +const TEST_USER_COMPANY = process.env.TEST_USER_COMPANY || 'Prowler'; + +test.describe('Sign In', () => { + test.beforeEach(async ({ page }) => { + // Clear any existing authentication state + await page.context().clearCookies(); + }); + + test('should successfully login with valid credentials and sign out', async ({ page }) => { + await page.goto('/sign-in'); + + // Fill in the login form + await page.getByLabel('Email').fill(TEST_USER_EMAIL); + await page.getByLabel('Password').fill(TEST_USER_PASSWORD); + + // Submit the form + await page.getByRole('button', { name: /log in/i }).click(); + + await expect(page).toHaveURL('/'); + + // Click user menu and then logout + await page.getByRole('button', { name: /sign out/i }).click(); + }); + + test('should show error with invalid credentials', async ({ page }) => { + await page.goto('/sign-in'); + + // Fill in the login form with invalid credentials + await page.getByLabel('Email').fill(TEST_USER_EMAIL); + await page.getByLabel('Password').fill('wrongpassword'); + + // Submit the form + await page.getByRole('button', { name: /log in/i }).click(); + + // Verify error message is shown + await expect(page.getByText(/invalid email or password/i)).toBeVisible(); + + // Assert border color + const emailInput = page.getByLabel('Email'); + const passwordInput = page.getByLabel('Password'); + const emailInputBorderColor = await emailInput.evaluate((el) => { + return window.getComputedStyle(el).borderColor; + }); + const passwordInputBorderColor = await passwordInput.evaluate((el) => { + return window.getComputedStyle(el).borderColor; + }); + expect(emailInputBorderColor).toBe('rgb(229, 231, 235)'); + expect(passwordInputBorderColor).toBe('rgb(229, 231, 235)'); + }); + + test('should maintain session after page reload', async ({ page }) => { + // First login + await page.goto('/sign-in'); + await page.getByLabel('Email').fill(TEST_USER_EMAIL); + await page.getByLabel('Password').fill(TEST_USER_PASSWORD); + await page.getByRole('button', { name: /log in/i }).click(); + + // Wait for successful login + await expect(page).toHaveURL('/'); + + // Reload the page + await page.reload(); + + // Verify still logged in + await expect(page).toHaveURL('/'); + }); +}); + +test.describe('Sign Up', () => { + test.beforeEach(async ({ page }) => { + // Clear any existing authentication state + await page.context().clearCookies(); + }); + + test('should successfully register a new user, then sign in and sign out', async ({ page }) => { + await page.goto('/sign-up'); + + // Fill in the login form + await page.getByPlaceholder('Enter your name').fill(TEST_USER_NAME); + await page.getByPlaceholder('Enter your company name').fill(TEST_USER_COMPANY); + // Generate a unique email for each run + const randomEmail = `e2e+${Date.now()}@prowler.com`; + await page.getByLabel('Email').fill(randomEmail); + await page.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD); + await page.getByLabel('Confirm Password', { exact: true }).fill(TEST_USER_PASSWORD); + + // Submit the form + await page.getByRole('button', { name: /sign up/i }).click(); + + // Assert the success message + await expect(page.getByText('Success!', { exact: true })).toBeVisible(); + await expect(page.getByText('The user was registered successfully.', { exact: true })).toBeVisible(); + + // Optionally, continue to assert the redirect + await expect(page).toHaveURL('/sign-in'); + + // Fill in the login form + await page.getByLabel('Email').fill(randomEmail); + await page.getByLabel('Password').fill(TEST_USER_PASSWORD); + + // Submit the form + await page.getByRole('button', { name: /log in/i }).click(); + + await expect(page).toHaveURL('/'); + + // Click user menu and then logout + await page.getByRole('button', { name: /sign out/i }).click(); + }); + + }); diff --git a/ui/tests/e2e/global-setup.ts b/ui/tests/e2e/global-setup.ts new file mode 100644 index 0000000000..229cb730fd --- /dev/null +++ b/ui/tests/e2e/global-setup.ts @@ -0,0 +1,20 @@ +import { FullConfig } from '@playwright/test'; +import { execSync } from 'child_process'; + +async function globalSetup(config: FullConfig) { + // Start Docker containers + console.log('Starting Docker containers...'); + execSync('docker-compose -f ../docker-compose-dev.yml up -d api-dev postgres valkey worker-beat worker-dev', { stdio: 'inherit' }); + + // Wait for services to be ready + console.log('Waiting for services to be ready...'); + await new Promise(resolve => setTimeout(resolve, 30000)); // Wait 30 seconds for services to be ready + + // Register cleanup function + process.on('exit', () => { + console.log('Stopping Docker containers...'); + execSync('docker-compose -f ../docker-compose-dev.yml down', { stdio: 'inherit' }); + }); +} + +export default globalSetup;