import { beforeEach, describe, expect, it } from 'vitest'; import { outbox } from '../src/lib/mail'; import { bearer, createUser, useTestApp } from './helpers'; const app = useTestApp(); beforeEach(() => { outbox.length = 0; }); /** The email is sent in the background; give it a moment. */ async function nextMail() { for (let i = 0; i < 50 && outbox.length === 0; i++) await new Promise((r) => setTimeout(r, 10)); return outbox.at(-1); } const tokenFrom = (text: string) => new URL(text.match(/http\S+/)![0]).searchParams.get('token')!; async function requestReset(email: string) { return app().inject({ method: 'POST', url: '/auth/forgot-password', payload: { email } }); } describe('password reset', () => { it('emails a link that sets a new password, signs out other sessions and signs in', async () => { const { token: oldSession } = await createUser(app(), 'alice'); expect((await requestReset(' ALICE@example.com ')).json()).toEqual({ ok: true }); const mail = await nextMail(); expect(mail).toMatchObject({ to: 'alice@example.com', subject: 'Reset your musicshare password' }); expect(mail!.text).toContain('http://web.test/reset-password?token='); expect(mail!.html).toContain('Choose a new password'); const res = await app().inject({ method: 'POST', url: '/auth/reset-password', payload: { token: tokenFrom(mail!.text), password: 'brand-new-pass' }, }); expect(res.statusCode).toBe(200); expect(res.json().user.username).toBe('alice'); expect((await app().inject({ url: '/me', headers: bearer(oldSession) })).statusCode).toBe(401); const login = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'alice@example.com', password: 'brand-new-pass' }, }); expect(login.statusCode).toBe(200); }); it("doesn't reveal whether an email has an account", async () => { const res = await requestReset('nobody@example.com'); expect(res.json()).toEqual({ ok: true }); await new Promise((r) => setTimeout(r, 100)); expect(outbox).toHaveLength(0); }); it('links work once, and a new request replaces the old link', async () => { await createUser(app(), 'alice'); await requestReset('alice@example.com'); const first = tokenFrom((await nextMail())!.text); outbox.length = 0; await requestReset('alice@example.com'); const second = tokenFrom((await nextMail())!.text); const reset = (token: string) => app().inject({ method: 'POST', url: '/auth/reset-password', payload: { token, password: 'another-pass-1' } }); expect((await reset(first)).statusCode).toBe(400); expect((await reset(second)).statusCode).toBe(200); const again = await reset(second); expect(again.statusCode).toBe(400); expect(again.json().error).toMatch(/expired or was already used/); }); it('rejects short passwords', async () => { await createUser(app(), 'alice'); await requestReset('alice@example.com'); const res = await app().inject({ method: 'POST', url: '/auth/reset-password', payload: { token: tokenFrom((await nextMail())!.text), password: 'short' }, }); expect(res.statusCode).toBe(400); expect(res.json().error).toMatch(/at least 8/); }); });