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>
122 lines
3.7 KiB
TypeScript
122 lines
3.7 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
|
import ResetPasswordPage from '@/pages/ResetPasswordPage.vue'
|
|
|
|
function mockFetchResponse(status: number, body: unknown) {
|
|
return Promise.resolve({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: () => Promise.resolve(body),
|
|
})
|
|
}
|
|
|
|
function createTestRouter() {
|
|
return createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [
|
|
{
|
|
path: '/aterstall-losenord',
|
|
name: 'reset-password',
|
|
component: ResetPasswordPage,
|
|
},
|
|
{
|
|
path: '/logga-in',
|
|
name: 'login',
|
|
component: { template: '<div>Login</div>' },
|
|
},
|
|
{
|
|
path: '/glomt-losenord',
|
|
name: 'forgot-password',
|
|
component: { template: '<div>Forgot</div>' },
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
async function mountPage(initialPath: string) {
|
|
const router = createTestRouter()
|
|
await router.push(initialPath)
|
|
await router.isReady()
|
|
return {
|
|
router,
|
|
wrapper: mount(ResetPasswordPage, {
|
|
global: { plugins: [router] },
|
|
}),
|
|
}
|
|
}
|
|
|
|
describe('ResetPasswordPage', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
globalThis.fetch = vi.fn()
|
|
vi.mocked(globalThis.fetch).mockResolvedValue(
|
|
mockFetchResponse(200, {
|
|
message: 'Lösenordet har uppdaterats. Du kan nu logga in.',
|
|
}),
|
|
)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('shows error when token query is missing', async () => {
|
|
const { wrapper } = await mountPage('/aterstall-losenord')
|
|
await vi.waitFor(() => {
|
|
expect(wrapper.text()).toContain(
|
|
'Återställningslänken saknar en giltig kod.',
|
|
)
|
|
})
|
|
})
|
|
|
|
it('shows password min length hint', async () => {
|
|
const { wrapper } = await mountPage('/aterstall-losenord?token=abc')
|
|
await wrapper.find('#password').setValue('short')
|
|
expect(wrapper.text()).toContain('Lösenordet måste vara minst 8 tecken')
|
|
})
|
|
|
|
it('shows mismatch hint when confirm password differs', async () => {
|
|
const { wrapper } = await mountPage('/aterstall-losenord?token=abc')
|
|
await wrapper.find('#password').setValue('password1234')
|
|
await wrapper.find('#confirmPassword').setValue('different1234')
|
|
expect(wrapper.text()).toContain('Lösenorden matchar inte')
|
|
})
|
|
|
|
it('shows success and navigates to login after reset', async () => {
|
|
const { wrapper, router } = await mountPage('/aterstall-losenord?token=abc')
|
|
await wrapper.find('#password').setValue('newpassword123')
|
|
await wrapper.find('#confirmPassword').setValue('newpassword123')
|
|
await wrapper.find('form').trigger('submit.prevent')
|
|
|
|
await vi.waitFor(() => {
|
|
expect(wrapper.text()).toContain(
|
|
'Lösenordet har uppdaterats. Du kan nu logga in.',
|
|
)
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(2000)
|
|
expect(router.currentRoute.value.path).toBe('/logga-in')
|
|
})
|
|
|
|
it('shows invalid token message from backend', async () => {
|
|
vi.mocked(globalThis.fetch).mockResolvedValue(
|
|
mockFetchResponse(400, {
|
|
message: 'Återställningslänken är ogiltig eller har gått ut',
|
|
}),
|
|
)
|
|
const { wrapper } = await mountPage('/aterstall-losenord?token=bad')
|
|
await wrapper.find('#password').setValue('newpassword123')
|
|
await wrapper.find('#confirmPassword').setValue('newpassword123')
|
|
await wrapper.find('form').trigger('submit.prevent')
|
|
|
|
await vi.waitFor(() => {
|
|
expect(wrapper.text()).toContain(
|
|
'Återställningslänken är ogiltig eller har gått ut',
|
|
)
|
|
expect(wrapper.find('a[href="/glomt-losenord"]').text()).toContain(
|
|
'Begär ny länk',
|
|
)
|
|
})
|
|
})
|
|
})
|