Files
solidtime/e2e/profile.spec.ts

724 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import path from 'path';
async function goToProfilePage(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
}
function profileInformationForm(page: Page) {
return page
.getByRole('heading', { name: 'Profile Information', exact: true })
.locator('xpath=ancestor::*[descendant::form][1]');
}
async function saveProfileForm(page: Page): Promise<void> {
const form = profileInformationForm(page);
await form.getByRole('button', { name: 'Save' }).click();
await expect(form.getByText('Saved.', { exact: true })).toBeVisible();
}
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('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('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('profile photo can be uploaded, persists across reload, and can be removed', async ({
page,
}) => {
await goToProfilePage(page);
const form = profileInformationForm(page);
const profilePhoto = form.getByRole('img', { name: 'John Doe' });
await expect(profilePhoto).toBeVisible();
await expect(profilePhoto).toHaveAttribute('src', /ui-avatars\.com/);
await expect(form.getByRole('button', { name: 'Remove Photo' })).toBeHidden();
await form.locator('#photo').setInputFiles(path.resolve('resources/testfiles/test.png'));
await saveProfileForm(page);
await expect(profilePhoto).toHaveAttribute('src', /profile-photos/);
await expect(form.getByRole('button', { name: 'Remove Photo' })).toBeVisible();
await page.reload();
const reloadedForm = profileInformationForm(page);
const reloadedProfilePhoto = reloadedForm.getByRole('img', { name: 'John Doe' });
await expect(reloadedProfilePhoto).toHaveAttribute('src', /profile-photos/);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
reloadedForm.getByRole('button', { name: 'Remove Photo' }).click(),
]);
await expect(reloadedProfilePhoto).toHaveAttribute('src', /ui-avatars\.com/);
await expect(reloadedForm.getByRole('button', { name: 'Remove Photo' })).toBeHidden();
await page.reload();
const finalForm = profileInformationForm(page);
await expect(finalForm.getByRole('img', { name: 'John Doe' })).toHaveAttribute(
'src',
/ui-avatars\.com/
);
await expect(finalForm.getByRole('button', { name: 'Remove Photo' })).toBeHidden();
});
test('field-level validation errors render inline when the server returns 422', async ({
page,
}) => {
await goToProfilePage(page);
const form = profileInformationForm(page);
await form.getByLabel('Name').fill('a'.repeat(256));
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'PUT' &&
response.status() === 422
),
form.getByRole('button', { name: 'Save' }).click(),
]);
await expect(form.getByRole('alert').filter({ hasText: /255 characters/i })).toBeVisible();
});
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('after submitting a new email the pending-email banner is shown with a resend button', async ({
page,
}) => {
await goToProfilePage(page);
const newEmail = `pending+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
await expect(page.getByText(`A verification link was sent to`)).toBeVisible();
await expect(page.getByText(newEmail)).toBeVisible();
await expect(page.getByRole('button', { name: 'Resend verification email' })).toBeVisible();
});
test('clicking resend sends a second verification email and shows confirmation', async ({
page,
request,
}) => {
await goToProfilePage(page);
const newEmail = `resend+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
const beforeCount = await waitForEmailCount(request, newEmail, 'Verify Email Address', 1);
await page.getByRole('button', { name: 'Resend verification email' }).click();
await expect(page.getByText('Verification email sent.')).toBeVisible();
const afterCount = await waitForEmailCount(
request,
newEmail,
'Verify Email Address',
beforeCount + 1
);
expect(afterCount).toBeGreaterThan(beforeCount);
});
test('cancelling a pending email change clears it and hides the banner', async ({ page, ctx }) => {
const { email: currentEmail } = await getCurrentUserViaApi(ctx);
const newEmail = `cancel+${Date.now()}@test.com`;
await goToProfilePage(page);
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
// The pending-email banner is shown with the cancel control.
await expect(page.getByText('A verification link was sent to')).toBeVisible();
await expect(page.getByText(newEmail)).toBeVisible();
const cancelButton = page.getByRole('button', { name: 'Cancel email change' });
await expect(cancelButton).toBeVisible();
// Cancelling clears the pending email server-side (204).
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reset-pending-email') &&
response.request().method() === 'POST' &&
response.status() === 204
),
cancelButton.click(),
]);
// The banner disappears and the email field still shows the current address.
await expect(page.getByText('A verification link was sent to')).toBeHidden();
await expect(page.getByLabel('Email')).toHaveValue(currentEmail);
// The cancellation is persistent — still gone after a reload.
await page.reload();
await expect(page.getByText('A verification link was sent to')).toBeHidden();
await expect(page.getByLabel('Email')).toHaveValue(currentEmail);
});
test('re-submitting the same pending email does not send another verification email', async ({
page,
request,
}) => {
await goToProfilePage(page);
const newEmail = `dup+${Date.now()}@test.com`;
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
const beforeCount = await waitForEmailCount(request, newEmail, 'Verify Email Address', 1);
await page.getByLabel('Email').fill(newEmail);
await saveProfileForm(page);
await new Promise((r) => setTimeout(r, 1000));
const afterCount = await countEmailsWithSubject(request, newEmail, '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 users 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();
}
});
test('delete account shows an error when the password is wrong', async ({ page }) => {
await goToProfilePage(page);
await page.getByRole('button', { name: 'Delete Account' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByPlaceholder('Password').fill('not-the-real-password');
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'DELETE' &&
response.status() === 422
),
dialog.getByRole('button', { name: 'Delete Account' }).click(),
]);
await expect(dialog.getByRole('alert')).toBeVisible();
await expect(dialog).toBeVisible();
});
test('delete account succeeds with the correct password and logs the user out', async ({
page,
}) => {
await goToProfilePage(page);
await page.getByRole('button', { name: 'Delete Account' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
dialog.getByRole('button', { name: 'Delete Account' }).click(),
]);
await page.waitForURL(/\/login/);
});
async function createNewApiToken(page) {
await page.getByLabel('API Key Name').fill('NEW API KEY');
await Promise.all([
page.getByRole('button', { name: 'Create API Key' }).click(),
page.waitForResponse('**/users/me/api-tokens'),
]);
await expect(page.locator('body')).toContainText('API Token created successfully');
await page.getByRole('dialog').getByText('Close').click();
await expect(page.locator('body')).toContainText('NEW API KEY');
}
test('test that user can create an API key', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
});
test('test that creating an API key with empty name shows validation error', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
// Wait for the API Key Name input to be visible before interacting
const nameInput = page.getByLabel('API Key Name');
await expect(nameInput).toBeVisible();
// Ensure the API Key Name input is empty
await nameInput.fill('');
// Click the create button and wait for the 422 response
const [response] = await Promise.all([
page.waitForResponse('**/users/me/api-tokens'),
page.getByRole('button', { name: 'Create API Key' }).click(),
]);
expect(response.status()).toBe(422);
// Verify that an error notification is shown with validation message about the name field
await expect(page.getByText('name field is required')).toBeVisible({ timeout: 5000 });
});
test('test that user can delete an API key', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
page.getByLabel('Delete API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText(
'Are you sure you would like to delete this API token?'
);
await Promise.all([
page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(),
page.waitForResponse('**/users/me/api-tokens'),
]);
await expect(page.locator('body')).not.toContainText('NEW API KEY');
});
test('test that user can revoke an API key', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
page.getByLabel('Revoke API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText(
'Are you sure you would like to revoke this API token?'
);
await Promise.all([
page.getByRole('dialog').getByRole('button', { name: 'Revoke' }).click(),
page.waitForResponse('**/users/me/api-tokens'),
]);
await expect(page.getByRole('button', { name: 'Revoke' })).toBeHidden();
await expect(page.locator('body')).toContainText('NEW API KEY');
await expect(page.locator('body')).toContainText('Revoked');
});
// =============================================
// Update Password Form Tests
// =============================================
test('test that password mismatch shows error', async ({ page }) => {
await goToProfilePage(page);
// Fill in with mismatched passwords
await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);
await page.getByLabel('New Password').fill('newSecurePassword456');
await page.getByLabel('Confirm Password').fill('differentPassword789');
// Find the form containing the Confirm Password field and click its Save button
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/user/password') && response.request().method() === 'PUT'
),
passwordForm.getByRole('button', { name: 'Save' }).click(),
]);
// Verify error message about password confirmation
await expect(page.getByText('confirmation does not match')).toBeVisible();
});
test('test that short password shows validation error', async ({ page }) => {
await goToProfilePage(page);
// Fill in with a too short password
await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);
await page.getByLabel('New Password').fill('short');
await page.getByLabel('Confirm Password').fill('short');
// Find the form containing the Confirm Password field and click its Save button
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/user/password') && response.request().method() === 'PUT'
),
passwordForm.getByRole('button', { name: 'Save' }).click(),
]);
// Verify error message about password length
await expect(page.getByText('must be at least')).toBeVisible();
});
test('test that incorrect current password shows validation error', async ({ page }) => {
await goToProfilePage(page);
// Fill in with wrong current password
await page.getByLabel('Current Password').fill('wrongCurrentPassword123');
await page.getByLabel('New Password').fill('newSecurePassword456');
await page.getByLabel('Confirm Password').fill('newSecurePassword456');
// Find the form containing the Confirm Password field and click its Save button
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/user/password') && response.request().method() === 'PUT'
),
passwordForm.getByRole('button', { name: 'Save' }).click(),
]);
// Verify error message about incorrect password
await expect(page.getByText('does not match')).toBeVisible();
});
test('test that password can be updated successfully', async ({ page }) => {
await goToProfilePage(page);
const newPassword = 'newSecurePassword456';
// Change password to new password
await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);
await page.getByLabel('New Password').fill(newPassword);
await page.getByLabel('Confirm Password').fill(newPassword);
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/user/password') && response.request().method() === 'PUT'
);
await passwordForm.getByRole('button', { name: 'Save' }).click();
const response = await responsePromise;
// Verify successful response (303 is Inertia redirect on success, means password was updated)
expect(response.status()).toBe(303);
// Verify no error messages are displayed
await expect(page.getByText('does not match')).not.toBeVisible();
await expect(page.getByText('must be at least')).not.toBeVisible();
});
// =============================================
// Theme Selection Tests
// =============================================
test('test that theme can be changed to dark and light', async ({ page }) => {
await goToProfilePage(page);
// The theme select is a Reka UI combobox (button), not a native <select>
const themeSelect = page.locator('button[role="combobox"]');
// Change theme to dark
await themeSelect.click();
await page.getByRole('option', { name: 'Dark' }).click();
// Verify the html element has 'dark' class
await expect(page.locator('html')).toHaveClass(/dark/);
// Change theme to light
await themeSelect.click();
await page.getByRole('option', { name: 'Light' }).click();
// Verify the html element has 'light' class and no 'dark' class
await expect(page.locator('html')).toHaveClass(/light/);
await expect(page.locator('html')).not.toHaveClass(/dark/);
// Verify localStorage persists the setting
const storedTheme = await page.evaluate(() => localStorage.getItem('theme'));
expect(storedTheme).toContain('light');
// Reload and verify the theme persists
await page.reload();
await expect(page.locator('html')).toHaveClass(/light/);
// Reset to system
await page.locator('button[role="combobox"]').click();
await page.getByRole('option', { name: 'System' }).click();
await expect(page.getByText('System default:')).toBeVisible();
});
// =============================================
// Group similar time entries
// =============================================
test('test that group similar time entries setting can be toggled', async ({ page }) => {
await goToProfilePage(page);
// Get the checkbox
const checkbox = page.getByLabel('Group similar time entries');
// Get initial value and verify it is checked (default is true)
const initialValue = await checkbox.isChecked();
await expect(checkbox).toBeChecked();
// Toggle the checkbox
await checkbox.click();
// Reload
await page.reload();
// Verify the value is toggled
const afterValue = await page.getByLabel('Group similar time entries').isChecked();
expect(afterValue).toBe(!initialValue);
// Verify localStorage persists the setting
const storedValue = await page.evaluate(() =>
localStorage.getItem('group-similar-time-entries')
);
expect(storedValue).toBe(String(!initialValue));
});
// =============================================
// Two Factor Authentication Tests
// =============================================
test('test that password confirmation modal can be cancelled without sending API request', async ({
page,
}) => {
await goToProfilePage(page);
// Find the Enable button in the 2FA section
const enableButton = page
.getByText('You have not enabled two factor authentication.')
.locator('..')
.getByRole('button', { name: 'Enable' });
await enableButton.click();
// Verify password confirmation modal appears
await expect(page.getByRole('dialog')).toBeVisible();
// Set up listener to verify no POST request is sent to confirm-password
let confirmPasswordRequestSent = false;
page.on('request', (request) => {
if (request.url().includes('/user/confirm-password') && request.method() === 'POST') {
confirmPasswordRequestSent = true;
}
});
// Click Cancel
await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();
// Verify modal is closed
await expect(page.getByRole('dialog')).not.toBeVisible();
// Verify no confirm-password request was sent
expect(confirmPasswordRequestSent).toBe(false);
});
test('test that password confirmation modal shows error for incorrect password', async ({
page,
}) => {
await goToProfilePage(page);
// Find the Enable button in the 2FA section
const enableButton = page
.getByText('You have not enabled two factor authentication.')
.locator('..')
.getByRole('button', { name: 'Enable' });
await enableButton.click();
// Verify password confirmation modal appears
await expect(page.getByRole('dialog')).toBeVisible();
// Enter incorrect password and confirm
await page.getByPlaceholder('Password').fill('wrongpassword123');
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
// Should show error message (wait longer for API response)
await expect(page.getByRole('dialog').getByText('incorrect')).toBeVisible({ timeout: 10000 });
});
test('test that 2FA can be enabled with correct password', async ({ page }) => {
await goToProfilePage(page);
// Verify 2FA is not enabled
await expect(page.getByText('You have not enabled two factor authentication.')).toBeVisible();
// Find the Enable button in the 2FA section
const enableButton = page
.getByText('You have not enabled two factor authentication.')
.locator('..')
.getByRole('button', { name: 'Enable' });
await enableButton.click();
// Verify password confirmation modal appears
await expect(page.getByRole('dialog')).toBeVisible();
// Enter correct password and confirm
await page.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await Promise.all([
page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/user/two-factor-authentication') &&
response.request().method() === 'POST'
),
]);
// Verify QR code is shown
await expect(page.getByRole('heading', { name: 'Finish enabling two factor' })).toBeVisible();
await expect(page.getByText('Setup Key:')).toBeVisible();
await expect(page.getByLabel('Code')).toBeVisible();
});
// =============================================
// Logout Other Browser Sessions Tests
// =============================================
test('test that logout other browser sessions works with correct password', async ({ page }) => {
await goToProfilePage(page);
await page.getByRole('button', { name: 'Log Out Other Browser Sessions' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await Promise.all([
page
.getByRole('dialog')
.getByRole('button', { name: 'Log Out Other Browser Sessions' })
.click(),
page.waitForResponse(
(response) =>
response.url().includes('/user/other-browser-sessions') &&
response.request().method() === 'DELETE'
),
]);
});