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; }); async function mailTo(to: string, subject: RegExp) { const find = () => outbox.filter((m) => m.to === to && subject.test(m.subject)).at(-1); for (let i = 0; i < 50 && !find(); i++) await new Promise((r) => setTimeout(r, 10)); return find(); } const tokenFrom = (text: string) => new URL(text.match(/http\S+verify-email\S+/)![0]).searchParams.get('token')!; const verify = (token: string) => app().inject({ method: 'POST', url: '/auth/verify-email', payload: { token } }); const me = async (token: string) => (await app().inject({ url: '/me', headers: bearer(token) })).json(); describe('email verification', () => { it('sends a confirmation link at sign-up that confirms the address', async () => { const { token, user } = await createUser(app(), 'alice'); expect(user.emailVerified).toBe(false); const mail = await mailTo('alice@example.com', /Confirm your musicshare email/); expect(mail!.text).toContain('http://web.test/verify-email?token='); const res = await verify(tokenFrom(mail!.text)); expect(res.json()).toEqual({ ok: true, email: 'alice@example.com' }); expect((await me(token)).emailVerified).toBe(true); expect((await verify(tokenFrom(mail!.text))).statusCode).toBe(400); // one use }); it('resends, and refuses once confirmed', async () => { const { token } = await createUser(app(), 'alice'); const resend = () => app().inject({ method: 'POST', url: '/me/verify-email/resend', headers: bearer(token), payload: {} }); expect((await resend()).statusCode).toBe(200); await verify(tokenFrom((await mailTo('alice@example.com', /Confirm/))!.text)); expect((await resend()).statusCode).toBe(400); }); }); describe('changing email', () => { it('only switches once the new address is confirmed, and warns the old one', async () => { const { token } = await createUser(app(), 'alice'); await verify(tokenFrom((await mailTo('alice@example.com', /Confirm/))!.text)); const res = await app().inject({ method: 'POST', url: '/me/email', headers: bearer(token), payload: { email: 'New@Example.com', password: 'password123' }, }); expect(res.json()).toMatchObject({ email: 'alice@example.com', pendingEmail: 'new@example.com' }); expect(await mailTo('alice@example.com', /being changed/)).toBeTruthy(); await verify(tokenFrom((await mailTo('new@example.com', /Confirm your new/))!.text)); expect(await me(token)).toMatchObject({ email: 'new@example.com', emailVerified: true, pendingEmail: null }); const login = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'new@example.com', password: 'password123' } }); expect(login.statusCode).toBe(200); }); it('needs the password, and a free address', async () => { const { token } = await createUser(app(), 'alice'); await createUser(app(), 'bob'); const change = (email: string, password: string) => app().inject({ method: 'POST', url: '/me/email', headers: bearer(token), payload: { email, password } }); expect((await change('x@example.com', 'wrong-password')).statusCode).toBe(403); expect((await change('bob@example.com', 'password123')).statusCode).toBe(409); }); it('can be cancelled', async () => { const { token } = await createUser(app(), 'alice'); await app().inject({ method: 'POST', url: '/me/email', headers: bearer(token), payload: { email: 'x@example.com', password: 'password123' } }); const link = tokenFrom((await mailTo('x@example.com', /Confirm/))!.text); const res = await app().inject({ method: 'DELETE', url: '/me/email/pending', headers: bearer(token) }); expect(res.json().pendingEmail).toBeNull(); expect((await verify(link)).statusCode).toBe(400); }); }); describe('changing password', () => { it('needs the current password and signs out other sessions only', async () => { const { token } = await createUser(app(), 'alice'); const other = ( await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'alice@example.com', password: 'password123' } }) ).json().token; const change = (currentPassword: string) => app().inject({ method: 'POST', url: '/me/password', headers: bearer(token), payload: { currentPassword, newPassword: 'better-pass-9' } }); expect((await change('nope')).statusCode).toBe(403); expect((await change('password123')).statusCode).toBe(200); expect((await app().inject({ url: '/me', headers: bearer(token) })).statusCode).toBe(200); expect((await app().inject({ url: '/me', headers: bearer(other) })).statusCode).toBe(401); const login = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'alice@example.com', password: 'better-pass-9' } }); expect(login.statusCode).toBe(200); }); });