bilhej/frontend/e2e/password-reset.spec.ts
Joakim Mörling 86fb946e33
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m2s
CI / E2E browser tests (push) Successful in 1m55s
Add password reset, logged-in change password, and Mailpit email dev/E2E.
Operators can fix prod admin passwords without email via Byt lösenord;
end users can use forgot-password when SMTP is configured. Local and CI
use Mailpit to capture outbound mail and verify reset links end-to-end.

- Backend: V8 password_reset_tokens, PasswordResetService, EmailService,
  POST /api/auth/forgot-password, reset-password, change-password
- Optional testToken in forgot-password response (docker profile only, for E2E)
- Frontend: ForgotPasswordPage, ResetPasswordPage, ChangePasswordPage,
  routes, login link, header Byt lösenord
- Mailpit (ghcr.io/axllent/mailpit:v1.28) in docker-compose + e2e stack
- E2E: password-reset.spec.ts + Mailpit API helper tests SMTP delivery
- Separate dev/e2e Docker image names to avoid overwriting bilhej-frontend
- Docs: README email section, production-email-checklist, .env.example
- Unit/integration tests for reset, change password, and Vitest page specs

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 18:05:15 +02:00

179 lines
6.1 KiB
TypeScript

import { test, expect } from '@playwright/test'
import {
clearMailpit,
countMessagesTo,
waitForPasswordResetToken,
} from './helpers/mailpit'
const forgotSuccessMessage =
'Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet.'
test.describe('Password reset', () => {
test('login page links to forgot password', async ({ page }) => {
await page.goto('/logga-in')
await page.getByRole('link', { name: 'Glömt lösenord?' }).click()
await expect(page).toHaveURL('/glomt-losenord')
await expect(
page.getByRole('heading', { name: 'Glömt lösenord?' }),
).toBeVisible()
})
test('forgot password page submits and shows success', async ({ page }) => {
await page.goto('/glomt-losenord')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page
.getByRole('button', { name: 'Skicka återställningslänk' })
.click()
await expect(page.getByText(forgotSuccessMessage)).toBeVisible()
})
test('unknown email gets same success message as known user', async ({
request,
}) => {
const known = await request.post('/api/auth/forgot-password', {
data: { email: 'test@bilhej.se' },
})
const unknown = await request.post('/api/auth/forgot-password', {
data: { email: 'nobody-reset-e2e@bilhej.se' },
})
expect(known.ok()).toBeTruthy()
expect(unknown.ok()).toBeTruthy()
const knownBody = await known.json()
const unknownBody = await unknown.json()
expect(knownBody.message).toBe(forgotSuccessMessage)
expect(unknownBody.message).toBe(forgotSuccessMessage)
expect(unknownBody.testToken).toBeUndefined()
})
test('full reset flow with isolated user', async ({ page, request }) => {
const email = `reset-e2e-${Date.now()}@bilhej.se`
const oldPassword = 'oldpass1234'
const newPassword = 'resetpass1234'
const register = await request.post('/api/auth/register', {
data: { email, password: oldPassword },
})
expect(register.ok()).toBeTruthy()
const forgot = await request.post('/api/auth/forgot-password', {
data: { email },
})
expect(forgot.ok()).toBeTruthy()
const forgotBody = await forgot.json()
expect(forgotBody.testToken).toBeTruthy()
await page.goto(`/aterstall-losenord?token=${forgotBody.testToken}`)
await page.getByLabel('Nytt lösenord').fill(newPassword)
await page.getByLabel('Bekräfta lösenord').fill(newPassword)
await page.getByRole('button', { name: 'Spara nytt lösenord' }).click()
await expect(
page.getByText('Lösenordet har uppdaterats. Du kan nu logga in.'),
).toBeVisible({ timeout: 10000 })
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill(email)
await page.getByLabel('Lösenord').fill(newPassword)
await page.getByRole('button', { name: 'Logga in' }).click()
await expect(page).toHaveURL('/')
})
test('login fails with old password after reset', async ({ request }) => {
const email = `reset-oldpw-${Date.now()}@bilhej.se`
const oldPassword = 'oldpass1234'
const newPassword = 'resetpass1234'
await request.post('/api/auth/register', {
data: { email, password: oldPassword },
})
const forgot = await request.post('/api/auth/forgot-password', {
data: { email },
})
const { testToken } = await forgot.json()
await request.post('/api/auth/reset-password', {
data: { token: testToken, password: newPassword },
})
const login = await request.post('/api/auth/login', {
data: { email, password: oldPassword },
})
expect(login.status()).toBe(401)
})
test('invalid token shows error and link to request new', async ({ page }) => {
await page.goto('/aterstall-losenord?token=invalid')
await page.getByLabel('Nytt lösenord').fill('newpassword123')
await page.getByLabel('Bekräfta lösenord').fill('newpassword123')
await page.getByRole('button', { name: 'Spara nytt lösenord' }).click()
await expect(
page.getByText('Återställningslänken är ogiltig eller har gått ut'),
).toBeVisible()
await expect(
page.getByRole('link', { name: 'Begär ny länk' }),
).toHaveAttribute('href', '/glomt-losenord')
})
test('missing token shows invalid link error', async ({ page }) => {
await page.goto('/aterstall-losenord')
await expect(
page.getByText('Återställningslänken saknar en giltig kod.'),
).toBeVisible()
await expect(
page.getByRole('link', { name: 'Begär ny länk' }),
).toHaveAttribute('href', '/glomt-losenord')
})
test('delivers reset link via Mailpit SMTP', async ({ page, request }) => {
const email = `mailpit-e2e-${Date.now()}@bilhej.se`
const oldPassword = 'oldpass1234'
const newPassword = 'mailpitpass1234'
await clearMailpit(request)
const register = await request.post('/api/auth/register', {
data: { email, password: oldPassword },
})
expect(register.ok()).toBeTruthy()
const forgot = await request.post('/api/auth/forgot-password', {
data: { email },
})
expect(forgot.ok()).toBeTruthy()
const token = await waitForPasswordResetToken(request, email, {
publicBaseUrl: 'http://frontend',
})
await page.goto(`/aterstall-losenord?token=${token}`)
await page.getByLabel('Nytt lösenord').fill(newPassword)
await page.getByLabel('Bekräfta lösenord').fill(newPassword)
await page.getByRole('button', { name: 'Spara nytt lösenord' }).click()
await expect(
page.getByText('Lösenordet har uppdaterats. Du kan nu logga in.'),
).toBeVisible({ timeout: 10000 })
const login = await request.post('/api/auth/login', {
data: { email, password: newPassword },
})
expect(login.ok()).toBeTruthy()
})
test('does not send Mailpit message for unknown email', async ({
request,
}) => {
await clearMailpit(request)
const forgot = await request.post('/api/auth/forgot-password', {
data: { email: 'nobody-mailpit-e2e@bilhej.se' },
})
expect(forgot.ok()).toBeTruthy()
await new Promise((resolve) => setTimeout(resolve, 2000))
expect(await countMessagesTo(request, 'nobody-mailpit-e2e@bilhej.se')).toBe(0)
})
})