Files
musicshare/apps/api/test/auth.test.ts
T
chrisandClaude Opus 5.5 d00eb6674e
CI / check (push) Canceled after 0s
Pick the music services you use
Songs offered links to all five services. Settings now has "Your music
services": pick the ones you use and songs only show those, with the
main one as the big button. Until someone picks, they still see all of
them. Onboarding sets it to the app you said you listen with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:45:29 -04:00

131 lines
6.2 KiB
TypeScript

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);
});
});
describe('music services', () => {
it('saves the services you use, without duplicates, and clears them', async () => {
const { token, user } = await createUser(app(), 'listener');
expect(user.services).toBeNull();
const patch = (payload: object) => app().inject({ method: 'PATCH', url: '/me', headers: bearer(token), payload });
const saved = (await patch({ services: ['spotify', 'tidal', 'spotify'], preferredService: 'tidal' })).json();
expect(saved.services).toEqual(['spotify', 'tidal']);
expect(saved.preferredService).toBe('tidal');
expect((await patch({ services: ['napster'] })).statusCode).toBe(400);
expect((await patch({ services: null })).json().services).toBeNull();
});
});