import { afterEach, describe, expect, it, vi } from 'vitest'; import { sql } from '../src/db'; import { bearer, createUser, json, routeFetch, useTestApp } from './helpers'; const app = useTestApp(); afterEach(() => vi.unstubAllGlobals()); describe('auth', () => { it('registers, returns a token, and authenticates with bearer or cookie', async () => { const res = await app().inject({ method: 'POST', url: '/auth/register', payload: { username: 'Alice', email: ' alice@example.com ', password: 'password123' }, }); expect(res.statusCode).toBe(200); const { user, token } = res.json(); expect(user).toMatchObject({ username: 'alice', email: 'alice@example.com', plan: 'free' }); expect(user).not.toHaveProperty('passwordHash'); const cookie = res.cookies.find((c) => c.name === 'ms_session'); expect(cookie).toMatchObject({ httpOnly: true, sameSite: 'Lax' }); const viaBearer = await app().inject({ url: '/me', headers: bearer(token) }); const viaCookie = await app().inject({ url: '/me', cookies: { ms_session: cookie!.value } }); expect(viaBearer.json().id).toBe(user.id); expect(viaCookie.json().id).toBe(user.id); }); it('serves the same routes under /api for proxies that keep the prefix', async () => { const res = await app().inject({ method: 'POST', url: '/api/auth/register', payload: { username: 'bob', email: 'bob@example.com', password: 'password123' }, }); expect(res.statusCode).toBe(200); const me = await app().inject({ url: '/api/me?x=1', headers: bearer(res.json().token) }); expect(me.json().username).toBe('bob'); expect((await app().inject({ url: '/apiary' })).statusCode).toBe(404); }); it('stores only a hash of the session token', async () => { const { token } = await createUser(app(), 'alice'); const rows = await sql`select id from sessions`; expect(rows).toHaveLength(1); expect(rows[0]!.id).not.toBe(token); }); it('rejects duplicate usernames and emails', async () => { await createUser(app(), 'alice'); const dupName = await app().inject({ method: 'POST', url: '/auth/register', payload: { username: 'ALICE', email: 'other@example.com', password: 'password123' }, }); const dupEmail = await app().inject({ method: 'POST', url: '/auth/register', payload: { username: 'other', email: 'Alice@Example.com', password: 'password123' }, }); expect(dupName.statusCode).toBe(409); expect(dupName.json().error).toBe('Username taken'); expect(dupEmail.statusCode).toBe(409); expect(dupEmail.json().error).toBe('Email already registered'); }); it('returns a readable 400 for invalid input', async () => { const res = await app().inject({ method: 'POST', url: '/auth/register', payload: { username: 'al', email: 'a@b.co', password: 'password123' }, }); expect(res.statusCode).toBe(400); expect(res.json().error).toMatch(/Username must be 3-32 characters/); }); it('gives the same error for unknown email and wrong password', async () => { await createUser(app(), 'alice'); const wrongPw = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'alice@example.com', password: 'nope' } }); const noUser = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'ghost@example.com', password: 'nope' } }); expect(wrongPw.statusCode).toBe(401); expect(noUser.statusCode).toBe(401); expect(wrongPw.json()).toEqual(noUser.json()); }); it('logout invalidates the session', async () => { const { token } = await createUser(app(), 'alice'); await app().inject({ method: 'POST', url: '/auth/logout', headers: bearer(token), payload: {} }); expect((await app().inject({ url: '/me', headers: bearer(token) })).statusCode).toBe(401); }); it('account deletion removes the user and everything attached', async () => { const alice = await createUser(app(), 'alice'); const bob = await createUser(app(), 'bob'); await app().inject({ method: 'POST', url: '/users/bob/follow', headers: bearer(alice.token), payload: {} }); vi.stubGlobal('fetch', routeFetch([[/listenbrainz\.org/, () => json({ payload: { listens: [] } })]])); const connected = await app().inject({ method: 'POST', url: '/connections/listenbrainz', headers: bearer(alice.token), payload: { username: 'x' } }); expect(connected.statusCode).toBe(200); const res = await app().inject({ method: 'DELETE', url: '/me', headers: bearer(alice.token) }); expect(res.statusCode).toBe(200); expect((await app().inject({ url: '/me', headers: bearer(alice.token) })).statusCode).toBe(401); const [counts] = await sql`select (select count(*)::int from follows) as follows, (select count(*)::int from connections) as connections`; expect(counts).toEqual({ follows: 0, connections: 0 }); expect((await app().inject({ url: '/users/bob', headers: bearer(bob.token) })).json().followerCount).toBe(0); }); }); describe('onboarding flag', () => { it('new accounts start un-onboarded; finishing sets it once', async () => { const { token, user } = await createUser(app(), 'newbie'); expect(user.onboardedAt).toBeNull(); const first = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(token), payload: { onboarded: true } })).json(); expect(first.onboardedAt).toEqual(expect.any(String)); const again = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(token), payload: { onboarded: true } })).json(); expect(again.onboardedAt).toBe(first.onboardedAt); }); });