diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b1326a5f0..34791724d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -12,9 +12,11 @@ jobs: test: runs-on: - codebuild-defguard-core-runner-${{ github.run_id }}-${{ github.run_attempt }} - instance-size:2xlarge - outputs: - tests_ok: ${{ steps.run-test.outcome == 'success' }} + instance-size:medium + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - uses: actions/checkout@v6 @@ -30,13 +32,10 @@ jobs: - name: Export image tag run: | - # strip "refs/heads.” to get just the branch name BRANCH=${GITHUB_REF#refs/heads/} if [[ "$BRANCH" == release/* ]]; then - # replace '/' with '-' on release branches IMAGE_TAG=${BRANCH//\//-} else - # main/dev branch IMAGE_TAG=$BRANCH fi echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV @@ -76,33 +75,47 @@ jobs: working-directory: ./e2e run: pnpm install --frozen-lockfile + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('e2e/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- + - name: Install playwright chromium working-directory: ./e2e - run: npx playwright install chromium + run: | + if [[ "${{ steps.playwright-cache.outputs.cache-hit }}" == "true" ]]; then + # Browsers are cached; only install missing system dependencies. + npx playwright install-deps chromium + else + npx playwright install --with-deps chromium + fi - name: run tests id: run-test - continue-on-error: true working-directory: ./e2e env: DEFGUARD_LICENSE_KEY: ${{ secrets.DEFGUARD_LICENSE_KEY }} - run: pnpm test + run: pnpm test --shard=${{ matrix.shard }}/8 - name: Stop compose if: always() run: docker compose --file './docker-compose.e2e.yaml' down - uses: actions/upload-artifact@v7 - if: steps.run-test.outcome == 'failure' + if: failure() with: - name: playwright-report + name: playwright-report-shard-${{ matrix.shard }} path: | ./e2e/playwright-report retention-days: 7 trigger-dev-deploy: needs: test - if: ${{ github.event_name != 'pull_request' && github.ref_name == 'dev' }} + if: ${{ github.event_name != 'pull_request' && github.ref_name == 'dev' && needs.test.result == 'success' }} uses: ./.github/workflows/dev-deployment.yml secrets: inherit @@ -111,6 +124,6 @@ jobs: if: | github.event_name != 'pull_request' && startsWith(github.ref_name, 'release/') && - needs.test.outputs.tests_ok == 'true' + needs.test.result == 'success' uses: ./.github/workflows/staging-deployment.yml secrets: inherit diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml index 5cc1ea118..cc5eac6a5 100644 --- a/docker-compose.e2e.yaml +++ b/docker-compose.e2e.yaml @@ -16,6 +16,7 @@ services: DEFGUARD_DB_PASSWORD: defguard DEFGUARD_DB_NAME: defguard DEFGUARD_URL: http://localhost:8000 + DEFGUARD_LICENSE_KEY: ${DEFGUARD_LICENSE_KEY:-} RUST_BACKTRACE: 1 ports: - "8000:8000" diff --git a/e2e/config.ts b/e2e/config.ts index 9cb479378..4465abca1 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -13,7 +13,7 @@ const defaultConfig: TestsConfig = { BASE_URL: 'http://localhost:8000', CORE_BASE_URL: 'http://localhost:8000/api/v1', ENROLLMENT_URL: 'http://localhost:8080', - TEST_TIMEOUT: 240, + TEST_TIMEOUT: 60, }; const envConfig: Partial = { diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 191df2914..1347a210c 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -28,6 +28,14 @@ export default defineConfig({ globalSetup: './utils/globalSetup', timeout: testsConfig.TEST_TIMEOUT * 1000, testDir: './tests', + // Exclude files that consist entirely of skipped tests to avoid Playwright + // collecting and reporting them as empty suites on every shard. + testIgnore: [ + '**/enrollment.spec.ts', + '**/externalopenid.spec.ts', + '**/externalopenidmfa.spec.ts', + '**/openid.spec.ts', + ], /* Run tests in files in parallel */ fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ diff --git a/e2e/tests/apiTokens.spec.ts b/e2e/tests/apiTokens.spec.ts new file mode 100644 index 000000000..ec2fa6e28 --- /dev/null +++ b/e2e/tests/apiTokens.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; + +import { defaultUserAdmin, routes, testUserTemplate } from '../config'; +import { User } from '../types'; +import { createUser } from '../utils/controllers/createUser'; +import { loginBasic } from '../utils/controllers/login'; +import { dockerRestart } from '../utils/docker'; +import { waitForBase } from '../utils/waitForBase'; + +test.describe('API tokens management', () => { + let testUser: User; + const token_name = 'test token name'; + + test.beforeEach(() => { + dockerRestart(); + testUser = { ...testUserTemplate, username: 'test' }; + }); + + test('Add API token as default admin', async ({ page }) => { + await waitForBase(page); + await loginBasic(page, defaultUserAdmin); + await page.goto( + routes.base + routes.profile + defaultUserAdmin.username + routes.tab.api_tokens, + ); + await page.getByTestId('add-token').click(); + await page.getByTestId('field-name').fill(token_name); + await page.getByTestId('submit').click(); + const api_token = await page.getByTestId('copy-field').textContent(); + await page.getByTestId('close').click(); + + const row = await page + .locator('.table-row-container') + .filter({ hasText: token_name }); + await row.locator('.icon-button').click(); + await page.getByTestId('delete').click(); + await page.locator('button[data-variant="critical"]').click(); + await expect(row).not.toBeVisible(); + expect(api_token).toBeDefined(); + }); + + test('Add API token as new user with admin privileges', async ({ page, browser }) => { + await waitForBase(page); + await createUser(browser, testUser, ['admin']); + await loginBasic(page, testUser); + await page.goto( + routes.base + routes.profile + testUser.username + routes.tab.api_tokens, + ); + await page.getByTestId('add-token').click(); + await page.getByTestId('field-name').fill(token_name); + await page.getByTestId('submit').click(); + const api_token = await page.getByTestId('copy-field').textContent(); + await page.getByTestId('close').click(); + + const row = await page + .locator('.table-row-container') + .filter({ hasText: token_name }); + await row.locator('.icon-button').click(); + await page.getByTestId('delete').click(); + await page.locator('button[data-variant="critical"]').click(); + await expect(row).not.toBeVisible(); + expect(api_token).toBeDefined(); + }); +}); diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts deleted file mode 100644 index b9e1593bf..000000000 --- a/e2e/tests/auth.spec.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { TOTP } from 'totp-generator'; - -import { defaultUserAdmin, routes, testUserTemplate } from '../config'; -import { User } from '../types'; -import { createUser } from '../utils/controllers/createUser'; -import { loginBasic, loginRecoveryCodes, loginTOTP } from '../utils/controllers/login'; -import { logout } from '../utils/controllers/logout'; -import { enableEmailMFA } from '../utils/controllers/mfa/enableEmail'; -import { enableSecurityKey } from '../utils/controllers/mfa/enableSecurityKey'; -import { enableTOTP } from '../utils/controllers/mfa/enableTOTP'; -import { disableUser } from '../utils/controllers/toggleUserState'; -import { dockerRestart } from '../utils/docker'; -import { waitForBase } from '../utils/waitForBase'; -import { waitForPromise } from '../utils/waitForPromise'; -import { waitForRoute } from '../utils/waitForRoute'; - -const EMAIL_CODE_VALIDITY_TIME = 300; - -test.describe('Test user authentication', () => { - let testUser: User; - - test.beforeEach(() => { - dockerRestart(); - testUser = { ...testUserTemplate, username: 'test' }; - }); - - test('Basic auth with default admin', async ({ page }) => { - await waitForBase(page); - const responsePromise = page.waitForResponse('**/auth'); - await loginBasic(page, defaultUserAdmin); - const response = await responsePromise; - expect(response.ok()).toBeTruthy(); - }); - - test('Create user and login as him', async ({ page, browser }) => { - await waitForBase(page); - await createUser(browser, testUser); - const responsePromise = page.waitForResponse('**/auth'); - await loginBasic(page, testUser); - const response = await responsePromise; - expect(response.ok()).toBeTruthy(); - }); - - test('Login with admin user via TOTP', async ({ page, browser }) => { - await waitForBase(page); - await loginBasic(page, defaultUserAdmin); - const { secret } = await enableTOTP(browser, defaultUserAdmin); - const responsePromise = page.waitForResponse('**/auth'); - await loginTOTP(page, defaultUserAdmin, secret); - const response = await responsePromise; - expect(response.ok()).toBeTruthy(); - }); - - test('Login with user via TOTP', async ({ page, browser }) => { - await waitForBase(page); - await createUser(browser, testUser); - const { secret } = await enableTOTP(browser, testUser); - const responsePromise = page.waitForResponse('**/auth'); - await loginTOTP(page, testUser, secret); - const response = await responsePromise; - expect(response.ok()).toBeTruthy(); - }); - - test('Recovery code login', async ({ page, browser }) => { - await waitForBase(page); - await createUser(browser, testUser); - const { recoveryCodes } = await enableTOTP(browser, testUser); - expect(recoveryCodes).toBeDefined(); - if (!recoveryCodes) return; - expect(recoveryCodes?.length > 0).toBeTruthy(); - const responsePromise = page.waitForResponse('**/auth'); - await loginRecoveryCodes(page, testUser, recoveryCodes[0]); - const response = await responsePromise; - expect(response.ok()).toBeTruthy(); - }); - - test('Login with Email TOTP', async ({ page, browser }) => { - await waitForBase(page); - await createUser(browser, testUser); - const { secret } = await enableEmailMFA(browser, testUser); - - await loginBasic(page, testUser); - await page.goto(routes.base + routes.auth.email); - const { otp: code } = TOTP.generate(secret, { - digits: 6, - period: EMAIL_CODE_VALIDITY_TIME, //FIXME: Probably a bug, email codes should be walid for 60 seconds - }); - const responsePromise = page.waitForResponse('**/verify'); - await page.getByTestId('field-code').fill(code); - await page.locator('[type="submit"]').click(); - const response = await responsePromise; - expect(response.ok()).toBeTruthy(); - }); - - test('Login as disabled user', async ({ page, browser }) => { - await waitForBase(page); - await createUser(browser, testUser); - await disableUser(browser, testUser); - await page.goto(routes.base); - await waitForRoute(page, routes.auth.login); - await page.getByTestId('field-username').fill(testUser.username); - await page.getByTestId('field-password').fill(testUser.password); - await page.getByTestId('sign-in').click(); - const responsePromise = page.waitForResponse('**/auth'); - const response = await responsePromise; - expect(response.ok()).toBeFalsy(); - }); - - test('Logout when enabled', async ({ page, browser }) => { - await waitForBase(page); - await createUser(browser, testUser); - await loginBasic(page, testUser); - const responsePromise = page.waitForResponse('**/logout'); - await logout(page); - const response = await responsePromise; - expect(response.status()).toBe(200); - await waitForPromise(1000); - await expect(page.url()).toBe(routes.base + routes.auth.login); - }); - - test('Logout when disabled', async ({ page, browser }) => { - await waitForBase(page); - await createUser(browser, testUser); - await loginBasic(page, testUser); - await disableUser(browser, testUser); - const responsePromise = page.waitForResponse('**/logout'); - await logout(page); - const response = await responsePromise; - expect(response.status()).toBe(401); - }); - - test('Create user and log in with security key', async ({ page, browser, context }) => { - await waitForBase(page); - await createUser(browser, testUser); - const { credentialId, rpId, privateKey, userHandle } = await enableSecurityKey( - browser, - testUser, - 'key_name', - ); - await page.goto(routes.base); - await waitForRoute(page, routes.auth.login); - await page.getByTestId('field-username').fill(testUser.username); - await page.getByTestId('field-password').fill(testUser.password); - await page.getByTestId('sign-in').click(); - await page.waitForTimeout(1000); - - const authenticator = await context.newCDPSession(page); - await authenticator.send('WebAuthn.enable'); - const { authenticatorId: loginAuthenticatorId } = await authenticator.send( - 'WebAuthn.addVirtualAuthenticator', - { - options: { - protocol: 'ctap2', - transport: 'usb', - hasResidentKey: true, - hasUserVerification: true, - isUserVerified: true, - }, - }, - ); - - await authenticator.send('WebAuthn.addCredential', { - authenticatorId: loginAuthenticatorId, - credential: { - credentialId, - isResidentCredential: true, - rpId, - privateKey, - userHandle, - signCount: 1, - }, - }); - const responsePromise = page.waitForResponse('**/me'); - await page.getByTestId('login-with-passkey').click(); - await page.waitForTimeout(2000); - const response = await responsePromise; - expect(response.ok()).toBeTruthy(); - }); -}); - -test.describe('Test password change', () => { - let testUser: User; - const newPassword = 'MyNewPassword1!@#$'; - - test.beforeEach(() => { - dockerRestart(); - testUser = { ...testUserTemplate, username: 'test' }; - }); - - test('Change user password', async ({ page, browser }) => { - await waitForBase(page); - await createUser(browser, testUser); - await loginBasic(page, testUser); - await page.getByTestId('change-password').click(); - await page.getByTestId('field-current').fill(testUser.password); - await page.getByTestId('field-password').fill(newPassword); - await page.getByTestId('field-repeat').fill(newPassword); - await page.getByTestId('submit-password-change').click(); - await logout(page); - testUser.password = newPassword; - const responsePromise = page.waitForResponse('**/auth'); - await loginBasic(page, testUser); - const response = await responsePromise; - expect(response.ok()).toBeTruthy(); - }); - - test('Change user password by admin', async ({ page, browser }) => { - await waitForBase(page); - await createUser(browser, testUser); - await loginBasic(page, defaultUserAdmin); - await page.goto(routes.base + routes.identity.users); - const userRow = await page - .locator('.virtual-row') - .filter({ hasText: testUser.username }); - await userRow.locator('.icon-button').click(); - await page.getByTestId('change-password').click(); - await page.getByTestId('field-password').fill(newPassword); - await page.getByTestId('submit-password-change').click(); - await logout(page); - testUser.password = newPassword; - const responsePromise = page.waitForResponse('**/auth'); - await loginBasic(page, testUser); - const response = await responsePromise; - expect(response.ok()).toBeTruthy(); - }); -}); - -test.describe('API tokens management', () => { - let testUser: User; - const token_name = 'test token name'; - test.beforeEach(() => { - dockerRestart(); - testUser = { ...testUserTemplate, username: 'test' }; - }); - test('Add API token as default admin', async ({ page }) => { - await waitForBase(page); - await loginBasic(page, defaultUserAdmin); - await page.goto( - routes.base + routes.profile + defaultUserAdmin.username + routes.tab.api_tokens, - ); - await page.getByTestId('add-token').click(); - await page.getByTestId('field-name').fill(token_name); - await page.getByTestId('submit').click(); - const api_token = await page.getByTestId('copy-field').textContent(); - await page.getByTestId('close').click(); - - const row = await page - .locator('.table-row-container') - .filter({ hasText: token_name }); - await row.locator('.icon-button').click(); - await page.getByTestId('delete').click(); - await page.locator('button[data-variant="critical"]').click(); - await expect(row).not.toBeVisible(); - expect(api_token).toBeDefined(); - }); - test('Add API token as new user with admin privileges', async ({ page, browser }) => { - await waitForBase(page); - await createUser(browser, testUser, ['admin']); - await loginBasic(page, testUser); - await page.goto( - routes.base + routes.profile + testUser.username + routes.tab.api_tokens, - ); - await page.getByTestId('add-token').click(); - await page.getByTestId('field-name').fill(token_name); - await page.getByTestId('submit').click(); - const api_token = await page.getByTestId('copy-field').textContent(); - await page.getByTestId('close').click(); - - const row = await page - .locator('.table-row-container') - .filter({ hasText: token_name }); - await row.locator('.icon-button').click(); - await page.getByTestId('delete').click(); - await page.locator('button[data-variant="critical"]').click(); - await expect(row).not.toBeVisible(); - expect(api_token).toBeDefined(); - }); -}); diff --git a/e2e/tests/auth/basicAuth.spec.ts b/e2e/tests/auth/basicAuth.spec.ts new file mode 100644 index 000000000..dab8cf9e4 --- /dev/null +++ b/e2e/tests/auth/basicAuth.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; + +import { defaultUserAdmin, routes, testUserTemplate } from '../../config'; +import { User } from '../../types'; +import { createUser } from '../../utils/controllers/createUser'; +import { loginBasic } from '../../utils/controllers/login'; +import { disableUser } from '../../utils/controllers/toggleUserState'; +import { dockerRestart } from '../../utils/docker'; +import { waitForBase } from '../../utils/waitForBase'; +import { waitForRoute } from '../../utils/waitForRoute'; + +test.describe('Basic authentication', () => { + let testUser: User; + + test.beforeEach(() => { + dockerRestart(); + testUser = { ...testUserTemplate, username: 'test' }; + }); + + test('Basic auth with default admin', async ({ page }) => { + await waitForBase(page); + const responsePromise = page.waitForResponse('**/auth'); + await loginBasic(page, defaultUserAdmin); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + }); + + test('Create user and login as him', async ({ page, browser }) => { + await waitForBase(page); + await createUser(browser, testUser); + const responsePromise = page.waitForResponse('**/auth'); + await loginBasic(page, testUser); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + }); + + test('Login as disabled user', async ({ page, browser }) => { + await waitForBase(page); + await createUser(browser, testUser); + await disableUser(browser, testUser); + await page.goto(routes.base); + await waitForRoute(page, routes.auth.login); + await page.getByTestId('field-username').fill(testUser.username); + await page.getByTestId('field-password').fill(testUser.password); + await page.getByTestId('sign-in').click(); + const responsePromise = page.waitForResponse('**/auth'); + const response = await responsePromise; + expect(response.ok()).toBeFalsy(); + }); +}); diff --git a/e2e/tests/auth/logout.spec.ts b/e2e/tests/auth/logout.spec.ts new file mode 100644 index 000000000..b3a2c89cd --- /dev/null +++ b/e2e/tests/auth/logout.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; + +import { routes, testUserTemplate } from '../../config'; +import { User } from '../../types'; +import { createUser } from '../../utils/controllers/createUser'; +import { loginBasic } from '../../utils/controllers/login'; +import { logout } from '../../utils/controllers/logout'; +import { disableUser } from '../../utils/controllers/toggleUserState'; +import { dockerRestart } from '../../utils/docker'; +import { waitForBase } from '../../utils/waitForBase'; +import { waitForRoute } from '../../utils/waitForRoute'; + +test.describe('Logout', () => { + let testUser: User; + + test.beforeEach(() => { + dockerRestart(); + testUser = { ...testUserTemplate, username: 'test' }; + }); + + test('Logout when enabled', async ({ page, browser }) => { + await waitForBase(page); + await createUser(browser, testUser); + await loginBasic(page, testUser); + const responsePromise = page.waitForResponse('**/logout'); + await logout(page); + const response = await responsePromise; + expect(response.status()).toBe(200); + await waitForRoute(page, routes.auth.login); + await expect(page).toHaveURL(routes.base + routes.auth.login); + }); + + test('Logout when disabled', async ({ page, browser }) => { + await waitForBase(page); + await createUser(browser, testUser); + await loginBasic(page, testUser); + await disableUser(browser, testUser); + const responsePromise = page.waitForResponse('**/logout'); + await logout(page); + const response = await responsePromise; + expect(response.status()).toBe(401); + }); +}); diff --git a/e2e/tests/auth/mfaAuth.spec.ts b/e2e/tests/auth/mfaAuth.spec.ts new file mode 100644 index 000000000..f90df4ca5 --- /dev/null +++ b/e2e/tests/auth/mfaAuth.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from '@playwright/test'; +import { TOTP } from 'totp-generator'; + +import { routes, testUserTemplate } from '../../config'; +import { User } from '../../types'; +import { createUser } from '../../utils/controllers/createUser'; +import { loginBasic } from '../../utils/controllers/login'; +import { enableEmailMFA } from '../../utils/controllers/mfa/enableEmail'; +import { enableSecurityKey } from '../../utils/controllers/mfa/enableSecurityKey'; +import { dockerRestart } from '../../utils/docker'; +import { waitForBase } from '../../utils/waitForBase'; +import { waitForRoute } from '../../utils/waitForRoute'; + +const EMAIL_CODE_VALIDITY_TIME = 300; + +test.describe('MFA authentication', () => { + let testUser: User; + + test.beforeEach(() => { + dockerRestart(); + testUser = { ...testUserTemplate, username: 'test' }; + }); + + test('Login with Email TOTP', async ({ page, browser }) => { + await waitForBase(page); + await createUser(browser, testUser); + const { secret } = await enableEmailMFA(browser, testUser); + + await loginBasic(page, testUser); + await page.goto(routes.base + routes.auth.email); + const { otp: code } = TOTP.generate(secret, { + digits: 6, + period: EMAIL_CODE_VALIDITY_TIME, //FIXME: Probably a bug, email codes should be valid for 60 seconds + }); + const responsePromise = page.waitForResponse('**/verify'); + await page.getByTestId('field-code').fill(code); + await page.locator('[type="submit"]').click(); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + }); + + test('Login with security key', async ({ page, browser, context }) => { + await waitForBase(page); + await createUser(browser, testUser); + const { credentialId, rpId, privateKey, userHandle } = await enableSecurityKey( + browser, + testUser, + 'key_name', + ); + await page.goto(routes.base); + await waitForRoute(page, routes.auth.login); + await page.getByTestId('field-username').fill(testUser.username); + await page.getByTestId('field-password').fill(testUser.password); + await page.getByTestId('sign-in').click(); + await page.getByTestId('login-with-passkey').waitFor({ state: 'visible' }); + + const authenticator = await context.newCDPSession(page); + await authenticator.send('WebAuthn.enable'); + const { authenticatorId: loginAuthenticatorId } = await authenticator.send( + 'WebAuthn.addVirtualAuthenticator', + { + options: { + protocol: 'ctap2', + transport: 'usb', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + }, + }, + ); + + await authenticator.send('WebAuthn.addCredential', { + authenticatorId: loginAuthenticatorId, + credential: { + credentialId, + isResidentCredential: true, + rpId, + privateKey, + userHandle, + signCount: 1, + }, + }); + const responsePromise = page.waitForResponse('**/me'); + await page.getByTestId('login-with-passkey').click(); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + }); +}); diff --git a/e2e/tests/auth/totpAuth.spec.ts b/e2e/tests/auth/totpAuth.spec.ts new file mode 100644 index 000000000..9dd2874ea --- /dev/null +++ b/e2e/tests/auth/totpAuth.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; + +import { defaultUserAdmin, testUserTemplate } from '../../config'; +import { User } from '../../types'; +import { createUser } from '../../utils/controllers/createUser'; +import { loginBasic, loginRecoveryCodes, loginTOTP } from '../../utils/controllers/login'; +import { enableTOTP } from '../../utils/controllers/mfa/enableTOTP'; +import { dockerRestart } from '../../utils/docker'; +import { waitForBase } from '../../utils/waitForBase'; + +test.describe('TOTP authentication', () => { + let testUser: User; + + test.beforeEach(() => { + dockerRestart(); + testUser = { ...testUserTemplate, username: 'test' }; + }); + + test('Login with admin user via TOTP', async ({ page, browser }) => { + await waitForBase(page); + await loginBasic(page, defaultUserAdmin); + const { secret } = await enableTOTP(browser, defaultUserAdmin); + const responsePromise = page.waitForResponse('**/auth'); + await loginTOTP(page, defaultUserAdmin, secret); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + }); + + test('Login with user via TOTP', async ({ page, browser }) => { + await waitForBase(page); + await createUser(browser, testUser); + const { secret } = await enableTOTP(browser, testUser); + const responsePromise = page.waitForResponse('**/auth'); + await loginTOTP(page, testUser, secret); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + }); + + test('Recovery code login', async ({ page, browser }) => { + await waitForBase(page); + await createUser(browser, testUser); + const { recoveryCodes } = await enableTOTP(browser, testUser); + expect(recoveryCodes).toBeDefined(); + if (!recoveryCodes) return; + expect(recoveryCodes?.length > 0).toBeTruthy(); + const responsePromise = page.waitForResponse('**/auth'); + await loginRecoveryCodes(page, testUser, recoveryCodes[0]); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + }); +}); diff --git a/e2e/tests/device.spec.ts b/e2e/tests/device.spec.ts index 41619d5d5..2f9bc8942 100644 --- a/e2e/tests/device.spec.ts +++ b/e2e/tests/device.spec.ts @@ -22,7 +22,9 @@ const testNetwork: NetworkForm = { port: '5055', }; -test.describe('Add user device', () => { +test.describe.skip('Add user device', () => { + // Skipped: devices field missing from user profile API response. + // https://github.com/DefGuard/defguard/issues/2606 const testUser: User = { ...testUserTemplate, username: 'test' }; const device_name = 'test'; test.beforeEach(async ({ browser }) => { diff --git a/e2e/tests/groups.spec.ts b/e2e/tests/groups.spec.ts index 46b503326..088721477 100644 --- a/e2e/tests/groups.spec.ts +++ b/e2e/tests/groups.spec.ts @@ -7,7 +7,6 @@ import { createGroup } from '../utils/controllers/groups'; import { loginBasic } from '../utils/controllers/login'; import { dockerRestart } from '../utils/docker'; import { waitForBase } from '../utils/waitForBase'; -import { waitForPromise } from '../utils/waitForPromise'; test.describe('Test groups', () => { test.beforeEach(() => dockerRestart()); @@ -19,7 +18,6 @@ test.describe('Test groups', () => { await createGroup(browser, group); } await loginBasic(page, defaultUserAdmin); - await waitForPromise(1000); await page.goto(routes.base + routes.identity.groups); for (const group of groups) { await expect(page.locator('text=' + group + '')).toBeVisible(); @@ -42,7 +40,8 @@ test.describe('Test groups', () => { await createGroup(browser, 'test_group2'); await createUser(browser, testUser, ['test_group2']); await loginBasic(page, testUser); - await expect(page.url()).toBe( + // Use toHaveURL (retries) not expect(page.url()).toBe (synchronous, no retry). + await expect(page).toHaveURL( routes.base + routes.profile + testUser.username + routes.tab.details, ); }); @@ -75,7 +74,7 @@ test.describe('Test groups', () => { .filter({ hasText: group_name }) .click(); await page.getByTestId('submit').click(); - await waitForPromise(2000); + await page.locator('.modal').waitFor({ state: 'hidden' }); await page.goto(routes.base + routes.identity.users); await expect(firstUser).toContainText(group_name); diff --git a/e2e/tests/openid.spec.ts b/e2e/tests/openid.spec.ts index 3bf91812a..34a046e4c 100644 --- a/e2e/tests/openid.spec.ts +++ b/e2e/tests/openid.spec.ts @@ -48,7 +48,7 @@ test.describe('Authorize OpenID client.', () => { .textContent(); expect(headerMessage?.replace(' ', '')).toBe('Success!'); await page.goto(routes.base + routes.me, { - waitUntil: 'networkidle', + waitUntil: 'load', }); const authorizedApps = page.locator('#authorized-apps-card').locator('.app'); await expect(authorizedApps).toContainText(client.name); @@ -71,7 +71,7 @@ test.describe('Authorize OpenID client.', () => { .textContent(); expect(headerMessage?.replace(' ', '')).toBe('Success!'); await page.goto(routes.base + routes.me, { - waitUntil: 'networkidle', + waitUntil: 'load', }); const authorizedApps = page.locator('#authorized-apps-card').locator('.app'); await expect(authorizedApps).toContainText(client.name); @@ -97,7 +97,7 @@ test.describe('Authorize OpenID client.', () => { .textContent(); expect(headerMessage?.replace(' ', '')).toBe('Success!'); await page.goto(routes.base + routes.me, { - waitUntil: 'networkidle', + waitUntil: 'load', }); const authorizedApps = page.locator('#authorized-apps-card').locator('.app'); await expect(authorizedApps).toContainText(client.name); diff --git a/e2e/tests/passwordChange.spec.ts b/e2e/tests/passwordChange.spec.ts new file mode 100644 index 000000000..da95f439d --- /dev/null +++ b/e2e/tests/passwordChange.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; + +import { defaultUserAdmin, routes, testUserTemplate } from '../config'; +import { User } from '../types'; +import { createUser } from '../utils/controllers/createUser'; +import { loginBasic } from '../utils/controllers/login'; +import { logout } from '../utils/controllers/logout'; +import { dockerRestart } from '../utils/docker'; +import { waitForBase } from '../utils/waitForBase'; + +test.describe('Test password change', () => { + let testUser: User; + const newPassword = 'MyNewPassword1!@#$'; + + test.beforeEach(() => { + dockerRestart(); + testUser = { ...testUserTemplate, username: 'test' }; + }); + + test('Change user password', async ({ page, browser }) => { + await waitForBase(page); + await createUser(browser, testUser); + await loginBasic(page, testUser); + await page.getByTestId('change-password').click(); + await page.getByTestId('field-current').fill(testUser.password); + await page.getByTestId('field-password').fill(newPassword); + await page.getByTestId('field-repeat').fill(newPassword); + await page.getByTestId('submit-password-change').click(); + await logout(page); + testUser.password = newPassword; + const responsePromise = page.waitForResponse('**/auth'); + await loginBasic(page, testUser); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + }); + + test('Change user password by admin', async ({ page, browser }) => { + await waitForBase(page); + await createUser(browser, testUser); + await loginBasic(page, defaultUserAdmin); + await page.goto(routes.base + routes.identity.users); + const userRow = await page + .locator('.virtual-row') + .filter({ hasText: testUser.username }); + await userRow.locator('.icon-button').click(); + await page.getByTestId('change-password').click(); + await page.getByTestId('field-password').fill(newPassword); + await page.getByTestId('submit-password-change').click(); + await logout(page); + testUser.password = newPassword; + const responsePromise = page.waitForResponse('**/auth'); + await loginBasic(page, testUser); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + }); +}); diff --git a/e2e/tests/passwordReset.spec.ts b/e2e/tests/passwordReset.spec.ts index b68e621f3..e621f6a27 100644 --- a/e2e/tests/passwordReset.spec.ts +++ b/e2e/tests/passwordReset.spec.ts @@ -14,7 +14,6 @@ import { disableUser } from '../utils/controllers/toggleUserState'; import { getPasswordResetToken } from '../utils/db/getPasswordResetToken'; import { dockerRestart } from '../utils/docker'; import { waitForBase } from '../utils/waitForBase'; -import { waitForPromise } from '../utils/waitForPromise'; const newPassword = '!7(8o3aN8RoF'; @@ -29,14 +28,12 @@ test.describe('Reset password', () => { test('Reset user password', async ({ page }) => { await waitForBase(page); await page.goto(testsConfig.ENROLLMENT_URL); - await waitForPromise(2000); await selectPasswordReset(page); await setEmail(user.mail, page); - await waitForPromise(1000); const token = await getPasswordResetToken(user.mail); await page.goto(`${testsConfig.ENROLLMENT_URL}/password-reset/?token=${token}`); - await waitForPromise(1000); + await page.getByTestId('field-password').waitFor({ state: 'visible' }); await setPassword(newPassword, page); const goToLogin = page.locator('button[data-variant="primary"]'); @@ -52,13 +49,10 @@ test.describe('Reset password', () => { test.skip('Reset disabled user password', async ({ page, browser }) => { await waitForBase(page); await page.goto(testsConfig.ENROLLMENT_URL); - await waitForPromise(2000); await selectPasswordReset(page); await setEmail(user.mail, page); - await waitForPromise(2000); const token = await getPasswordResetToken(user.mail); await disableUser(browser, user); - await waitForPromise(5000); await page.goto(`${testsConfig.ENROLLMENT_URL}/password-reset/?token=${token}`); // A message should be displayed that the code is invalid diff --git a/e2e/tests/webhook.spec.ts b/e2e/tests/webhook.spec.ts index 82d65e562..722daa956 100644 --- a/e2e/tests/webhook.spec.ts +++ b/e2e/tests/webhook.spec.ts @@ -4,7 +4,6 @@ import { defaultUserAdmin, routes } from '../config'; import { loginBasic } from '../utils/controllers/login'; import { createWebhook } from '../utils/controllers/webhook'; import { dockerRestart } from '../utils/docker'; -import { waitForPromise } from '../utils/waitForPromise'; test.describe('Test webhooks', () => { test.beforeEach(() => { @@ -18,7 +17,7 @@ test.describe('Test webhooks', () => { await createWebhook(browser, webhook_url, webhook_description, webhook_secret); await loginBasic(page, defaultUserAdmin); await page.goto(routes.base + routes.webhooks, { - waitUntil: 'networkidle', + waitUntil: 'load', }); const webhookRow = await page .locator('.virtual-row') @@ -35,7 +34,7 @@ test.describe('Test webhooks', () => { await createWebhook(browser, webhook_url, webhook_description, 'secret'); await loginBasic(page, defaultUserAdmin); await page.goto(routes.base + routes.webhooks, { - waitUntil: 'networkidle', + waitUntil: 'load', }); const webhookRow = await page .locator('.virtual-row') @@ -50,10 +49,11 @@ test.describe('Test webhooks', () => { await page.getByTestId('field-url').fill(new_webhook_url); await page.getByTestId('field-description').fill(new_webhook_description); + const responsePromise = page.waitForResponse('**/webhook/**'); await page.getByTestId('submit').click(); - await waitForPromise(2000); + await responsePromise; await page.goto(routes.base + routes.webhooks, { - waitUntil: 'networkidle', + waitUntil: 'load', }); const new_webhookRow = await page @@ -68,7 +68,7 @@ test.describe('Test webhooks', () => { await createWebhook(browser, webhook_url, webhook_description, webhook_secret); await loginBasic(page, defaultUserAdmin); await page.goto(routes.base + routes.webhooks, { - waitUntil: 'networkidle', + waitUntil: 'load', }); const webhookRow = await page .locator('.virtual-row') @@ -96,7 +96,7 @@ test.describe('Test webhooks', () => { await createWebhook(browser, webhook_url, webhook_description, webhook_secret); await loginBasic(page, defaultUserAdmin); await page.goto(routes.base + routes.webhooks, { - waitUntil: 'networkidle', + waitUntil: 'load', }); const webhookRow = await page .locator('.virtual-row') diff --git a/e2e/utils/controllers/acceptRecovery.ts b/e2e/utils/controllers/acceptRecovery.ts index 3a9ae7c00..5a3f9a66f 100644 --- a/e2e/utils/controllers/acceptRecovery.ts +++ b/e2e/utils/controllers/acceptRecovery.ts @@ -1,13 +1,11 @@ import { Page } from 'playwright'; import { getPageClipboard } from '../getPageClipboard'; -import { waitForPromise } from '../waitForPromise'; // accepts recovery and returns codes export const acceptRecovery = async (page: Page): Promise => { try { - await waitForPromise(2000); - + await page.getByTestId('copy-recovery-codes').waitFor({ state: 'visible' }); await page.getByTestId('copy-recovery-codes').click(); const recoveryString = await getPageClipboard(page); const recovery = recoveryString.split('\n').filter((line) => line.trim()); diff --git a/e2e/utils/controllers/enrollment.ts b/e2e/utils/controllers/enrollment.ts index 7a2d16f87..751a1bae2 100644 --- a/e2e/utils/controllers/enrollment.ts +++ b/e2e/utils/controllers/enrollment.ts @@ -53,12 +53,13 @@ export const createUserEnrollment = async ( throw new Error('No token'); } + await context.close(); return { user, token }; }; export const selectEnrollment = async (page: Page) => { const selectButton = page.getByTestId('select-enrollment'); - selectButton.click(); + await selectButton.click(); }; export const setToken = async (token: string, page: Page) => { diff --git a/e2e/utils/controllers/groups.ts b/e2e/utils/controllers/groups.ts index 1dd1e37be..efee44c1d 100644 --- a/e2e/utils/controllers/groups.ts +++ b/e2e/utils/controllers/groups.ts @@ -2,7 +2,6 @@ import { Browser, expect } from 'playwright/test'; import { defaultUserAdmin } from '../../config'; import { waitForBase } from '../waitForBase'; -import { waitForPromise } from '../waitForPromise'; import { loginBasic } from './login'; export const createGroup = async (browser: Browser, group_name: string) => { @@ -15,6 +14,6 @@ export const createGroup = async (browser: Browser, group_name: string) => { await page.getByTestId('field-name').fill(group_name); await page.getByTestId('next').click(); await page.getByTestId('submit').click(); - await waitForPromise(1000); - expect(page.locator(':text("' + group_name + '")')).toBeVisible(); + await expect(page.locator(':text("' + group_name + '")')).toBeVisible(); + await context.close(); }; diff --git a/e2e/utils/controllers/login.ts b/e2e/utils/controllers/login.ts index c1bbd0c12..37baa3fc1 100644 --- a/e2e/utils/controllers/login.ts +++ b/e2e/utils/controllers/login.ts @@ -3,7 +3,6 @@ import { TOTP } from 'totp-generator'; import { routes, testsConfig } from '../../config'; import { User } from '../../types'; -import { waitForPromise } from '../waitForPromise'; import { waitForRoute } from '../waitForRoute'; type AuthInfo = User | Pick; @@ -16,8 +15,13 @@ export const loginBasic = async (page: Page, userInfo: AuthInfo) => { await waitForRoute(page, routes.auth.login); await page.getByTestId('field-username').fill(userInfo.username); await page.getByTestId('field-password').fill(userInfo.password); + // Set up the handler immediately before click to avoid matching pre-existing responses. + // Explicitly check POST to avoid matching any GET auth-check requests the SPA may fire. + const responsePromise = page.waitForResponse( + (resp) => resp.url().endsWith('/api/v1/auth') && resp.request().method() === 'POST', + ); await page.getByTestId('sign-in').click(); - await waitForPromise(1000); + await responsePromise; }; export const loginTOTP = async (page: Page, userInfo: AuthInfo, totpSecret: string) => { @@ -31,8 +35,11 @@ export const loginTOTP = async (page: Page, userInfo: AuthInfo, totpSecret: stri await codeField.clear(); const { otp: token } = TOTP.generate(totpSecret); await codeField.fill(token); + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/api/v1/auth') && resp.request().method() === 'POST', + ); await page.getByTestId('submit-totp').click(); - await waitForPromise(1000); + await responsePromise; }; export const loginRecoveryCodes = async ( @@ -46,9 +53,12 @@ export const loginRecoveryCodes = async ( await page.getByTestId('field-password').fill(userInfo.password); await page.getByTestId('sign-in').click(); await page.locator('a:has-text("Use recovery codes instead")').click(); - await waitForPromise(1000); + await page.getByTestId('field-code').waitFor({ state: 'visible' }); await page.getByTestId('field-code').clear(); await page.getByTestId('field-code').fill(code.trim()); + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/api/v1/auth') && resp.request().method() === 'POST', + ); await page.getByTestId('submit-recovery-code').click(); - await waitForPromise(1000); + await responsePromise; }; diff --git a/e2e/utils/controllers/mfa/enableEmail.ts b/e2e/utils/controllers/mfa/enableEmail.ts index c8e86f500..db6b14f4b 100644 --- a/e2e/utils/controllers/mfa/enableEmail.ts +++ b/e2e/utils/controllers/mfa/enableEmail.ts @@ -5,7 +5,6 @@ import { defaultUserAdmin, routes } from '../../../config'; import { User } from '../../../types'; import { extractEmailSecret } from '../../db/extractEmailSecret'; import { waitForBase } from '../../waitForBase'; -import { waitForPromise } from '../../waitForPromise'; import { acceptRecovery } from '../acceptRecovery'; import { loginBasic } from '../login'; import { logout } from '../logout'; @@ -19,9 +18,9 @@ export const setupSMTP = async (browser: Browser) => { const context = await browser.newContext(); const page = await context.newPage(); await waitForBase(page); - await waitForPromise(5000); await loginBasic(page, defaultUserAdmin); await page.goto(routes.base + routes.settings.smtp); + await page.getByTestId('field-smtp_server').waitFor({ state: 'visible' }); await page.getByTestId('field-smtp_server').fill('testServer.com'); await page.getByTestId('field-smtp_port').fill('543'); await page.getByTestId('field-smtp_user').fill('testuser'); @@ -31,7 +30,6 @@ export const setupSMTP = async (browser: Browser) => { if (await saveButton.isEnabled()) { await saveButton.click(); } - await waitForPromise(1000); await logout(page); await context.close(); }; @@ -44,12 +42,11 @@ export const enableEmailMFA = async ( const context = await browser.newContext(); const page = await context.newPage(); await waitForBase(page); - await waitForPromise(5000); await loginBasic(page, user); await page.goto(routes.base + routes.profile + user.username); await page.getByTestId('email-codes-row').locator('.icon-button').click(); await page.getByTestId('enable-email').click(); - await waitForPromise(2000); + await page.getByTestId('field-code').waitFor({ state: 'visible' }); const secret = await extractEmailSecret(user.username); const { otp: code } = TOTP.generate(secret, { digits: 6, diff --git a/e2e/utils/controllers/mfa/enableSecurityKey.ts b/e2e/utils/controllers/mfa/enableSecurityKey.ts index 43766dfc6..191d9aad4 100644 --- a/e2e/utils/controllers/mfa/enableSecurityKey.ts +++ b/e2e/utils/controllers/mfa/enableSecurityKey.ts @@ -25,11 +25,11 @@ export const enableSecurityKey = async ( await page.getByTestId('passkeys-row').locator('.icon-button').click(); await page.getByTestId('add-passkey').click(); await page.getByTestId('field-name').fill(keyName); - await page.getByTestId('submit').click(); + // Set up virtual authenticator before triggering the WebAuthn challenge + // so it can respond to navigator.credentials.create() automatically. const authenticator = await context.newCDPSession(page); await authenticator.send('WebAuthn.enable'); - const { authenticatorId } = await authenticator.send( 'WebAuthn.addVirtualAuthenticator', { @@ -42,9 +42,14 @@ export const enableSecurityKey = async ( }, }, ); - await page.waitForTimeout(2000); + + await page.getByTestId('submit').click(); + + // Wait for recovery codes screen to appear after WebAuthn registration completes + await page.getByTestId('confirm-code-save').waitFor({ state: 'visible' }); await page.getByTestId('confirm-code-save').click(); await page.getByTestId('finish-recovery-codes').click(); + const { credentials } = await authenticator.send('WebAuthn.getCredentials', { authenticatorId, }); diff --git a/e2e/utils/controllers/openid/copyClientId.ts b/e2e/utils/controllers/openid/copyClientId.ts index 50d314af2..92abbe1a5 100644 --- a/e2e/utils/controllers/openid/copyClientId.ts +++ b/e2e/utils/controllers/openid/copyClientId.ts @@ -10,11 +10,12 @@ export const copyOpenIdClientId = async (browser: Browser, clientId: number) => const page = await context.newPage(); await waitForBase(page); await loginBasic(page, defaultUserAdmin); - await page.goto(routes.base + routes.openid_apps, { waitUntil: 'networkidle' }); + await page.goto(routes.base + routes.openid_apps, { waitUntil: 'load' }); const deviceRow = page.locator('.virtual-row').nth(clientId - 1); await deviceRow.locator('.icon-button').click(); await page.getByTestId('copy-id').click(); const id = await getPageClipboard(page); + await context.close(); return id; }; @@ -26,7 +27,7 @@ export const copyOpenIdClientIdAndSecret = async ( const page = await context.newPage(); await waitForBase(page); await loginBasic(page, defaultUserAdmin); - await page.goto(routes.base + routes.openid_apps, { waitUntil: 'networkidle' }); + await page.goto(routes.base + routes.openid_apps, { waitUntil: 'load' }); const userRow = await page.locator('.virtual-row').filter({ hasText: clientName }); await userRow.locator('.icon-button').click(); await page.getByTestId('copy-id').click(); diff --git a/e2e/utils/controllers/openid/createOpenIdClient.ts b/e2e/utils/controllers/openid/createOpenIdClient.ts index 34c320233..afe17bc23 100644 --- a/e2e/utils/controllers/openid/createOpenIdClient.ts +++ b/e2e/utils/controllers/openid/createOpenIdClient.ts @@ -11,7 +11,7 @@ export const CreateOpenIdClient = async (browser: Browser, client: OpenIdClient) const page = await context.newPage(); await waitForBase(page); await loginBasic(page, defaultUserAdmin); - await page.goto(routes.base + routes.openid_apps, { waitUntil: 'networkidle' }); + await page.goto(routes.base + routes.openid_apps, { waitUntil: 'load' }); await page.getByTestId('add-new-app').click(); await page.getByTestId('field-name').fill(client.name); @@ -25,9 +25,8 @@ export const CreateOpenIdClient = async (browser: Browser, client: OpenIdClient) for (const scope of client.scopes) { await page.getByTestId(`field-scope-${scope}`).click(); } - await page.getByTestId('save-settings').click(); const responsePromise = page.waitForResponse('**/oauth'); - + await page.getByTestId('save-settings').click(); const resp = await responsePromise; expect(resp.status()).toBe(201); await context.close(); diff --git a/e2e/utils/controllers/passwordReset.ts b/e2e/utils/controllers/passwordReset.ts index 3b5976d76..7346b8c02 100644 --- a/e2e/utils/controllers/passwordReset.ts +++ b/e2e/utils/controllers/passwordReset.ts @@ -2,12 +2,16 @@ import { Page } from 'playwright'; export const selectPasswordReset = async (page: Page) => { const selectButton = page.getByTestId('start-password-reset'); - selectButton.click(); + await selectButton.waitFor({ state: 'visible' }); + await selectButton.click(); }; -export const setEmail = async (token: string, page: Page) => { - await page.getByTestId('field-email').fill(token); +export const setEmail = async (email: string, page: Page) => { + await page.getByTestId('field-email').waitFor({ state: 'visible' }); + await page.getByTestId('field-email').fill(email); await page.getByTestId('page-nav-next').click(); + // Wait for the email step to complete (field hidden = server processed the request). + await page.getByTestId('field-email').waitFor({ state: 'hidden' }); }; export const setPassword = async (password: string, page: Page) => { diff --git a/e2e/utils/controllers/vpn/createNetworkDevice.ts b/e2e/utils/controllers/vpn/createNetworkDevice.ts index a346e9b09..fcbbbb380 100644 --- a/e2e/utils/controllers/vpn/createNetworkDevice.ts +++ b/e2e/utils/controllers/vpn/createNetworkDevice.ts @@ -3,7 +3,6 @@ import { Browser, expect, Locator, Page } from '@playwright/test'; import { routes } from '../../../config'; import { EditNetworkDeviceForm, NetworkDeviceForm, User } from '../../../types'; import { getPageClipboard } from '../../getPageClipboard'; -import { waitForPromise } from '../../waitForPromise'; import { waitForRoute } from '../../waitForRoute'; import { loginBasic } from '../login'; @@ -50,7 +49,7 @@ export const createNetworkCLIDevice = async ( const page = await context.newPage(); await loginBasic(page, user); await page.goto(routes.base + routes.network_devices, { - waitUntil: 'networkidle', + waitUntil: 'load', }); await page.getByTestId('add-device').click(); await page.getByTestId('defguard-cli').click(); @@ -60,7 +59,6 @@ export const createNetworkCLIDevice = async ( } await page.getByTestId('submit').click(); await page.getByTestId('finish').click(); - await waitForPromise(1000); const deviceRow = page.locator('.virtual-row').filter({ hasText: device.name }); await expect(deviceRow).toContainText('Awaiting Setup'); await context.close(); @@ -87,7 +85,7 @@ export const startNetworkDeviceEnrollment = async ( await page.getByTestId('field-wireguard_pubkey').fill(device.pubKey); } await page.getByTestId('submit').click(); - await waitForPromise(2000); + await page.getByTestId('copy-config').waitFor({ state: 'visible' }); await page.getByTestId('copy-config').click(); await page.getByTestId('finish').click(); diff --git a/e2e/utils/docker.ts b/e2e/utils/docker.ts index a6eef9510..42e169e44 100644 --- a/e2e/utils/docker.ts +++ b/e2e/utils/docker.ts @@ -6,25 +6,37 @@ const defguardPath = __dirname.split('e2e')[0]; const dockerFilePath = path.resolve(defguardPath, 'docker-compose.e2e.yaml'); const dockerCompose = `docker compose -f ${dockerFilePath}`; +// Run a SQL statement in the postgres maintenance database. +const psql = (sql: string) => + execSync(`${dockerCompose} exec db psql -U defguard -d postgres -c "${sql}"`); + // Start Defguard stack with docker compose. export const dockerUp = () => { - const command = `${dockerCompose} up --wait`; - execSync(command); - // NOTE: After waiting, sleep for 3 seconds to let Defguard Core apply migrations. - const wait_for_db = `${dockerCompose} exec db sh -c 'until pg_isready; do sleep 1; done; sleep 3'`; - execSync(wait_for_db); - const create_snapshot = `${dockerCompose} exec db pg_dump -U defguard -Fc -f /tmp/defguard_backup.dump defguard`; - execSync(create_snapshot); + execSync(`${dockerCompose} up --wait`); + // Wait for DB to be ready and let Core apply migrations before proceeding. + execSync( + `${dockerCompose} exec db sh -c 'until pg_isready; do sleep 1; done; sleep 3'`, + ); }; +// Snapshot the current defguard database as a PostgreSQL template so it can +// be cloned instantly on each test reset. Core is briefly stopped to prevent +// active connections from blocking the template creation. export const dockerCreateSnapshot = () => { - const create_snapshot = `${dockerCompose} exec db pg_dump -U defguard -Fc -f /tmp/defguard_backup.dump defguard`; - execSync(create_snapshot); + execSync(`${dockerCompose} kill core`); + psql( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'defguard'", + ); + psql('DROP DATABASE IF EXISTS defguard_template'); + psql('CREATE DATABASE defguard_template TEMPLATE defguard OWNER defguard'); + execSync(`${dockerCompose} start core`); + execSync( + `until curl -sf http://localhost:8000/api/v1/health > /dev/null; do sleep 1; done`, + ); }; export const dockerCheckContainers = (): boolean => { - const command = `${dockerCompose} ps -q`; - const containers = execSync(command).toString().trim(); + const containers = execSync(`${dockerCompose} ps -q`).toString().trim(); return Boolean(containers.length); }; @@ -32,18 +44,20 @@ export const dockerRestart = () => { if (!dockerCheckContainers()) { dockerUp(); } else { - // Stop core first to avoid crashing due to terminated DB connections during restore. - const stop_core = `${dockerCompose} stop core`; - execSync(stop_core); - const restore = `${dockerCompose} exec db pg_restore --clean -U defguard -d defguard /tmp/defguard_backup.dump`; - execSync(restore); - const restart = `${dockerCompose} restart db`; - execSync(restart); - const wait_for_db = `${dockerCompose} exec db sh -c 'until pg_isready; do sleep 1; done'`; - execSync(wait_for_db); - const start_core = `${dockerCompose} start core`; - execSync(start_core); - const wait_for_core = `until curl -sf http://localhost:8000/api/v1/health > /dev/null; do sleep 1; done`; - execSync(wait_for_core); + // SIGKILL core immediately — no grace period needed in tests. + execSync(`${dockerCompose} kill core`); + // Terminate any connections PostgreSQL still sees (kernel closes sockets on + // SIGKILL but PostgreSQL may not have processed the hangup yet). + psql( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'defguard'", + ); + // Drop and instantly recreate defguard from the template (filesystem-level copy). + psql('DROP DATABASE defguard'); + psql('CREATE DATABASE defguard TEMPLATE defguard_template OWNER defguard'); + // Start core and wait for it to be healthy. + execSync(`${dockerCompose} start core`); + execSync( + `until curl -sf http://localhost:8000/api/v1/health > /dev/null; do sleep 1; done`, + ); } }; diff --git a/e2e/utils/globalSetup.ts b/e2e/utils/globalSetup.ts index 9626668da..0ce048674 100644 --- a/e2e/utils/globalSetup.ts +++ b/e2e/utils/globalSetup.ts @@ -3,7 +3,6 @@ import { chromium, request } from '@playwright/test'; import { defaultUserAdmin, testsConfig } from '../config'; import { dockerCheckContainers, dockerCreateSnapshot, dockerUp } from './docker'; import { loadEnv } from './loadEnv'; -import { waitForPromise } from './waitForPromise'; const setLicense = async () => { const license = process.env.DEFGUARD_LICENSE_KEY; @@ -22,7 +21,6 @@ const setLicense = async () => { throw new Error(`Auth failed with status ${authRes.status()}`); } - // defguard_session cookie is automatically stored in the context const patchRes = await ctx.patch('/api/v1/settings', { data: { license: license.trim() }, }); @@ -43,7 +41,8 @@ const waitForCore = async () => { await new Promise((resolve) => { const check = () => { const req = http.get(coreUrl.toString(), (res) => { - if (res.statusCode && res.statusCode < 500) { + // Require exactly 200 — setup server may return other codes during transition. + if (res.statusCode === 200) { resolve(); } else { setTimeout(check, 2000); @@ -55,80 +54,88 @@ const waitForCore = async () => { check(); }); }; + const runWizard = async () => { const browser = await chromium.launch({ headless: !process.env.HEADED }); const context = await browser.newContext(); const page = await context.newPage(); + page.setDefaultTimeout(testsConfig.TEST_TIMEOUT * 1000); - // Navigate to base URL — app redirects to wizard if setup not done await page.goto(testsConfig.BASE_URL); - // Step 1: Click "Configure Defguard" await page .getByRole('button', { name: 'Configure Defguard' }) .waitFor({ state: 'visible' }); await page.getByRole('button', { name: 'Configure Defguard' }).click(); - // Step 2: Fill admin user form await page.getByTestId('field-first_name').waitFor({ state: 'visible' }); await page.getByTestId('field-first_name').fill(defaultUserAdmin.firstName); await page.getByTestId('field-last_name').fill(defaultUserAdmin.lastName); await page.getByTestId('field-username').fill(defaultUserAdmin.username); await page.getByTestId('field-email').fill(defaultUserAdmin.mail); await page.getByTestId('field-password').fill(defaultUserAdmin.password); - - // Step 3: Continue to next step + const adminResp = page.waitForResponse( + (r) => r.url().includes('/initial_setup/admin') && r.request().method() === 'POST', + ); await page.getByRole('button', { name: 'Continue' }).click(); + await adminResp; - // Step 4: Fill Defguard URL and proxy URL - await page.getByTestId('field-defguard_url').waitFor({ state: 'visible' }); - await page - .getByTestId('field-defguard_url') - .fill(testsConfig.CORE_BASE_URL.replace('/api/v1', '')); - await page.getByTestId('field-public_proxy_url').fill(testsConfig.ENROLLMENT_URL); - - // Continue to CA step + await page.getByTestId('field-default_admin_group_name').waitFor({ state: 'visible' }); + const generalConfigResp = page.waitForResponse( + (r) => + r.url().includes('/initial_setup/general_config') && + r.request().method() === 'POST', + ); await page.getByRole('button', { name: 'Continue' }).click(); + await generalConfigResp; - // Step 5: Click "Create a certificate authority..." option (recommended) - await page.locator('.interactive-content').first().waitFor({ state: 'visible' }); - await page.locator('.interactive-content').first().click(); - - // Fill CA fields await page.getByTestId('field-ca_common_name').waitFor({ state: 'visible' }); await page.getByTestId('field-ca_common_name').fill('Defguard Test CA'); await page.getByTestId('field-ca_email').fill('ca@defguard.test'); - - // Continue + const caResp = page.waitForResponse( + (r) => r.url().includes('/initial_setup/ca') && r.request().method() === 'POST', + ); await page.getByRole('button', { name: 'Continue' }).click(); + await caResp; - // Step 6: CA summary — Continue await page.getByRole('button', { name: 'Continue' }).waitFor({ state: 'visible' }); await page.getByRole('button', { name: 'Continue' }).click(); - // Step 7: Confirm Edge deployment checkbox + Next await page.locator('.checkbox').waitFor({ state: 'visible' }); await page.locator('.checkbox').click(); - await page.getByRole('button', { name: 'Next' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); - // Step 8: Edge component — fill name and IP await page.getByTestId('field-common_name').waitFor({ state: 'visible' }); await page.getByTestId('field-common_name').fill('edge-test'); await page.getByTestId('field-ip_or_domain').fill('proxy'); - - // Adopt Edge component await page.getByRole('button', { name: 'Adopt Edge component' }).click(); - // Step 9: Edge adoption — Continue await page.getByRole('button', { name: 'Continue' }).waitFor({ state: 'visible' }); await page.getByRole('button', { name: 'Continue' }).click(); - // Step 10: "I'll do this later" + await page.getByTestId('field-defguard_url').waitFor({ state: 'visible' }); + await page + .getByTestId('field-defguard_url') + .fill(testsConfig.CORE_BASE_URL.replace('/api/v1', '')); + await page.getByRole('button', { name: 'Continue' }).click(); + + await page.getByRole('button', { name: 'Continue' }).waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Continue' }).click(); + + await page.getByTestId('field-public_proxy_url').waitFor({ state: 'visible' }); + await page.getByTestId('field-public_proxy_url').fill(testsConfig.ENROLLMENT_URL); + await page.getByRole('button', { name: 'Continue' }).click(); + + await page.getByRole('button', { name: 'Continue' }).waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Continue' }).click(); + await page .getByRole('button', { name: "I'll do this later" }) .waitFor({ state: 'visible' }); await page.getByRole('button', { name: "I'll do this later" }).click(); + await page.waitForURL('**/vpn-overview', { timeout: testsConfig.TEST_TIMEOUT * 1000 }); + await context.close(); await browser.close(); }; @@ -140,16 +147,17 @@ export default async function globalSetup() { dockerUp(); } - // Wait until core HTTP is ready before running the wizard. console.log('Waiting for Defguard Core to be ready...'); await waitForCore(); console.log('Core is ready. Running setup wizard...'); await runWizard(); - await waitForPromise(3000); + console.log('Wizard complete. Waiting for main Core to be ready...'); + await waitForCore(); + console.log('Main Core is ready.'); + await setLicense(); - // Overwrite the snapshot with post-wizard state. dockerCreateSnapshot(); } diff --git a/e2e/utils/waitForBase.ts b/e2e/utils/waitForBase.ts index 03a1fc5e8..6044cef39 100644 --- a/e2e/utils/waitForBase.ts +++ b/e2e/utils/waitForBase.ts @@ -1,19 +1,19 @@ import { Page } from '@playwright/test'; import { routes } from '../config'; -import { waitForPromise } from './waitForPromise'; -// Sometimes test cant react front at the beginning of the test, this is a workaround +// Retry navigation to the login page until it succeeds (e.g. after dockerRestart). export const waitForBase = async (page: Page): Promise => { let err = true; while (err) { try { await page.goto(routes.base + routes.auth.login, { - waitUntil: 'networkidle', + waitUntil: 'load', + timeout: 10000, }); err = false; } catch { - await waitForPromise(500); + await new Promise((resolve) => setTimeout(resolve, 500)); } } }; diff --git a/e2e/utils/waitForRoute.ts b/e2e/utils/waitForRoute.ts index 06d71c8ea..853bd5c23 100644 --- a/e2e/utils/waitForRoute.ts +++ b/e2e/utils/waitForRoute.ts @@ -2,6 +2,6 @@ import { Page } from 'playwright'; export const waitForRoute = async (page: Page, route: string) => { await page.waitForURL('**' + route, { - waitUntil: 'networkidle', + waitUntil: 'load', }); };