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>
179 lines
6.1 KiB
TypeScript
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)
|
|
})
|
|
})
|