From 8e7c8a1e1b16714adb0f44b66c7d020f711cf518 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 26 May 2026 17:11:28 +0200 Subject: [PATCH] add profile page e2e tests --- e2e/profile.spec.ts | 179 ++++++++++++++++++++++++++++++++++++++--- e2e/utils/api.ts | 13 +++ e2e/utils/mailpit.ts | 61 ++++++++++++++ playwright/fixtures.ts | 4 + 4 files changed, 246 insertions(+), 11 deletions(-) diff --git a/e2e/profile.spec.ts b/e2e/profile.spec.ts index 30231262..125556c9 100644 --- a/e2e/profile.spec.ts +++ b/e2e/profile.spec.ts @@ -1,30 +1,187 @@ import { test, expect } from '../playwright/fixtures'; import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config'; +import { + countEmailsWithSubject, + getEmailChangeVerificationUrl, + waitForEmailCount, +} from './utils/mailpit'; +import { getCurrentUserViaApi } from './utils/api'; +import { registerUser } from './utils/members'; import type { Page } from '@playwright/test'; async function goToProfilePage(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); } -test('test that user name can be updated', async ({ page }) => { - await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); - await page.getByLabel('Name', { exact: true }).fill('NEW NAME'); +async function saveProfileForm(page: Page): Promise { await Promise.all([ + page.waitForResponse( + (resp) => + resp.url().includes('/user/profile-information') && + resp.request().method() === 'POST' + ), page.getByRole('button', { name: 'Save' }).first().click(), - page.waitForResponse('**/user/profile-information'), ]); +} + +test('user name can be updated', async ({ page }) => { + await goToProfilePage(page); + await page.getByLabel('Name', { exact: true }).fill('NEW NAME'); + await saveProfileForm(page); await page.reload(); await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME'); }); -test.skip('test that user email can be updated', async ({ page }) => { - // this does not work because of email verification currently - await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); - const emailId = Math.round(Math.random() * 10000); - await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`); - await page.getByRole('button', { name: 'Save' }).first().click(); +test('timezone change persists across reload', async ({ page }) => { + await goToProfilePage(page); + await page.getByLabel('Timezone').selectOption('America/New_York'); + await saveProfileForm(page); await page.reload(); - await expect(page.getByLabel('Email')).toHaveValue(`newemail+${emailId}@test.com`); + await expect(page.getByLabel('Timezone')).toHaveValue('America/New_York'); +}); + +test('week-start change persists across reload', async ({ page }) => { + await goToProfilePage(page); + await page.getByLabel('Start of the week').selectOption('sunday'); + await saveProfileForm(page); + await page.reload(); + await expect(page.getByLabel('Start of the week')).toHaveValue('sunday'); +}); + +test('submitting a new email keeps the current email displayed after reload', async ({ + page, + ctx, +}) => { + const { email: oldEmail } = await getCurrentUserViaApi(ctx); + const newEmail = `newemail+${Date.now()}@test.com`; + + await goToProfilePage(page); + await page.getByLabel('Email').fill(newEmail); + await saveProfileForm(page); + await page.reload(); + + await expect(page.getByLabel('Email')).toHaveValue(oldEmail); +}); + +test('submitting a new email sends a verification email to the new address', async ({ + page, + request, +}) => { + await goToProfilePage(page); + const newEmail = `newemail+${Date.now()}@test.com`; + + await page.getByLabel('Email').fill(newEmail); + await saveProfileForm(page); + + expect(await waitForEmailCount(request, newEmail, 'Verify Email Address', 1)).toBeGreaterThan( + 0 + ); +}); + +test('mixed-case email is lower-cased before the verification mail is sent', async ({ + page, + request, +}) => { + await goToProfilePage(page); + const stamp = Date.now(); + const mixedCase = `MixedCase+${stamp}@Example.COM`; + const lowerCased = `mixedcase+${stamp}@example.com`; + + await page.getByLabel('Email').fill(mixedCase); + await saveProfileForm(page); + + const verifyUrl = await getEmailChangeVerificationUrl(request, lowerCased); + expect(new URL(verifyUrl).searchParams.get('email')).toBe(lowerCased); +}); + +test('re-submitting the current email does not send a verification email', async ({ + page, + ctx, + request, +}) => { + const { email: currentEmail } = await getCurrentUserViaApi(ctx); + const beforeCount = await countEmailsWithSubject(request, currentEmail, 'Verify Email Address'); + + await goToProfilePage(page); + await page.getByLabel('Email').fill(currentEmail); + await saveProfileForm(page); + + await new Promise((r) => setTimeout(r, 1000)); + const afterCount = await countEmailsWithSubject(request, currentEmail, 'Verify Email Address'); + expect(afterCount).toBe(beforeCount); +}); + +test('clicking the verification link swaps the email and shows a success banner', async ({ + page, +}) => { + await goToProfilePage(page); + const newEmail = `verify+${Date.now()}@test.com`; + await page.getByLabel('Email').fill(newEmail); + await saveProfileForm(page); + const verifyUrl = await getEmailChangeVerificationUrl(page.request, newEmail); + + await page.goto(verifyUrl); + await page.waitForURL(/\/dashboard/); + + const banner = page.getByTestId('banner'); + await expect(banner).toBeVisible(); + await expect(banner).toContainText('Your email address has been updated successfully.'); + + await goToProfilePage(page); + await expect(page.getByLabel('Email')).toHaveValue(newEmail); +}); + +test('visiting another user’s verification link is forbidden', async ({ page, browser }) => { + await goToProfilePage(page); + const newEmail = `victim+${Date.now()}@test.com`; + await page.getByLabel('Email').fill(newEmail); + await saveProfileForm(page); + const verifyUrl = await getEmailChangeVerificationUrl(page.request, newEmail); + + const other = await registerUser(browser, 'Other User', `other+${Date.now()}@test.com`); + try { + const response = await other.page.goto(verifyUrl); + expect(response?.status()).toBe(403); + } finally { + await other.close(); + } +}); + +test('a stale verification link from a previous submission is rejected', async ({ page }) => { + await goToProfilePage(page); + const stamp = Date.now(); + const olderEmail = `older+${stamp}@test.com`; + const newerEmail = `newer+${stamp}@test.com`; + + await page.getByLabel('Email').fill(olderEmail); + await saveProfileForm(page); + const staleUrl = await getEmailChangeVerificationUrl(page.request, olderEmail); + + await page.getByLabel('Email').fill(newerEmail); + await saveProfileForm(page); + + const response = await page.goto(staleUrl); + expect(response?.status()).toBe(403); +}); + +test('visiting the verification link while logged out redirects to login', async ({ + page, + browser, +}) => { + await goToProfilePage(page); + const newEmail = `loggedout+${Date.now()}@test.com`; + await page.getByLabel('Email').fill(newEmail); + await saveProfileForm(page); + const verifyUrl = await getEmailChangeVerificationUrl(page.request, newEmail); + + const anonContext = await browser.newContext(); + try { + const anonPage = await anonContext.newPage(); + await anonPage.goto(verifyUrl); + await anonPage.waitForURL(/\/login/); + } finally { + await anonContext.close(); + } }); async function createNewApiToken(page) { diff --git a/e2e/utils/api.ts b/e2e/utils/api.ts index 35998a0a..ab86c79d 100644 --- a/e2e/utils/api.ts +++ b/e2e/utils/api.ts @@ -649,6 +649,19 @@ export async function createTimeEntryWithTimestampsViaApi( // User profile helpers // ────────────────────────────────────────────────── +export async function getCurrentUserViaApi(ctx: TestContext) { + const response = await ctx.request.get(`${PLAYWRIGHT_BASE_URL}/api/v1/users/me`); + expect(response.status()).toBe(200); + const body = await response.json(); + return body.data as { + id: string; + name: string; + email: string; + timezone: string; + week_start: string; + }; +} + export async function updateUserProfileViaWeb( page: Page, settings: { timezone?: string; week_start?: string } diff --git a/e2e/utils/mailpit.ts b/e2e/utils/mailpit.ts index bfdd007f..4df6723a 100644 --- a/e2e/utils/mailpit.ts +++ b/e2e/utils/mailpit.ts @@ -81,3 +81,64 @@ export async function getPasswordResetUrl( return resetUrlMatch![1].replace(/&/g, '&'); } + +/** + * Count emails matching the given subject sent to the given address. + */ +export async function countEmailsWithSubject( + request: APIRequestContext, + recipientEmail: string, + subject: string +): Promise { + const searchResult = await searchEmails( + request, + `to:${encodeURIComponent(recipientEmail)} subject:"${subject}"` + ); + return searchResult.messages.length; +} + +/** + * Poll Mailpit until the count of matching emails reaches `min`, or 5 attempts + * (~2.5s) elapse. Returns the final count. + */ +export async function waitForEmailCount( + request: APIRequestContext, + recipientEmail: string, + subject: string, + min: number +): Promise { + let count = 0; + for (let attempt = 0; attempt < 5; attempt++) { + count = await countEmailsWithSubject(request, recipientEmail, subject); + if (count >= min) break; + await new Promise((r) => setTimeout(r, 500)); + } + return count; +} + +/** + * Find the email-change verification URL from a Mailpit email sent to the given address. + * Retries a few times to allow for email delivery delay. + */ +export async function getEmailChangeVerificationUrl( + request: APIRequestContext, + recipientEmail: string +): Promise { + let searchResult: { messages: Array<{ ID: string }> } = { messages: [] }; + + for (let attempt = 0; attempt < 5; attempt++) { + searchResult = await searchEmails( + request, + `to:${encodeURIComponent(recipientEmail)} subject:"Verify Email Address"` + ); + if (searchResult.messages.length > 0) break; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + expect(searchResult.messages.length).toBeGreaterThan(0); + + const message = await getMessage(request, searchResult.messages[0].ID); + const verifyUrlMatch = message.HTML.match(/href="([^"]*verify-email-change[^"]*)"/); + expect(verifyUrlMatch).toBeTruthy(); + + return verifyUrlMatch![1].replace(/&/g, '&'); +} diff --git a/playwright/fixtures.ts b/playwright/fixtures.ts index 877e7cf3..41695593 100644 --- a/playwright/fixtures.ts +++ b/playwright/fixtures.ts @@ -32,6 +32,9 @@ export const test = baseTest.extend< const email = `john+${Date.now()}_${Math.floor(Math.random() * 10000)}@doe.com`; const password = TEST_USER_PASSWORD; const name = 'John Doe'; + const timezone = await page.evaluate( + () => Intl.DateTimeFormat().resolvedOptions().timeZone + ); // Use page.context().request() so cookies are automatically shared with the page const request = page.context().request; @@ -64,6 +67,7 @@ export const test = baseTest.extend< password, password_confirmation: password, terms: 'on', + timezone, }, maxRedirects: 0, });