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
86 lines
3.4 KiB
TypeScript
86 lines
3.4 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;
|
|
});
|
|
|
|
/** The reset email is sent in the background; give it a moment. (Sign-up sends its own confirmation email.) */
|
|
async function nextMail() {
|
|
const reset = () => outbox.filter((m) => m.subject.startsWith('Reset')).at(-1);
|
|
for (let i = 0; i < 50 && !reset(); i++) await new Promise((r) => setTimeout(r, 10));
|
|
return reset();
|
|
}
|
|
|
|
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.filter((m) => m.subject.startsWith('Reset'))).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/);
|
|
});
|
|
});
|