Files
musicshare/apps/api/test/password-reset.test.ts
chrisandClaude Opus 5.5 85e1643c29
CI / check (push) Canceled after 0s
Email confirmation, change email/password, show/hide passwords
- 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
2026-10-06 13:05:26 -04:00

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/);
});
});