CI / check (push) Canceled after 0s
- Sign-up emails a confirmation link (24h); unconfirmed accounts see a banner on the web with "send it again", and status in Settings. - Change email (needs the password): the new address gets a confirmation link and only replaces the old one once clicked; the old address gets a heads-up with a reset-password link. Pending changes can be cancelled. - Change password (needs the current one): signs out other sessions. - Password fields have Show/Hide on web and mobile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
97 lines
4.9 KiB
TypeScript
97 lines
4.9 KiB
TypeScript
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);
|
|
});
|
|
});
|