CI / check (push) Canceled after 0s
"Forgot your password?" on web and mobile sends a one-time link (1 hour) to the account's email; the website's reset page sets the new password, signs out every other session and signs in. The request responds the same whether or not the email has an account, and the email goes out in the background so timing doesn't reveal it either. Only a hash of the link token is stored. Sends through SMTP_* settings; without them, emails are logged (and captured in tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
85 lines
3.2 KiB
TypeScript
85 lines
3.2 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 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/);
|
|
});
|
|
});
|