import { createHash } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { db, schema } from '../src/db'; import { decrypt } from '../src/lib/crypto'; import { lastfmSignature } from '../src/providers/lastfm'; import { bearer, createUser, json, routeFetch, useTestApp } from './helpers'; const app = useTestApp(); afterEach(() => vi.unstubAllGlobals()); const stateFrom = (url: string) => decodeURIComponent(url).match(/state=([^&]+)/)![1]!; describe('Last.fm sign-in', () => { it('signs requests per the Last.fm rules: sorted name+value pairs, secret appended, md5', () => { const expected = createHash('md5').update('api_keykmethodmtokent' + 's').digest('hex'); expect(lastfmSignature({ token: 't', method: 'm', api_key: 'k' }, 's')).toBe(expected); }); it('runs the web auth flow and stores the username and session key', async () => { let sessionUrl = ''; vi.stubGlobal( 'fetch', routeFetch([[/auth\.getSession/, (url) => ((sessionUrl = url), json({ session: { name: 'RJ', key: 'sk-123', subscriber: 0 } }))]]), ); const { token, user } = await createUser(app(), 'alice'); const start = await app().inject({ method: 'POST', url: '/connections/lastfm/authorize', headers: bearer(token), payload: { client: 'mobile', returnTo: 'musicshare://settings' }, }); const authUrl = new URL(start.json().url); expect(authUrl.host).toBe('www.last.fm'); expect(authUrl.searchParams.get('api_key')).toBe('test-lastfm-key'); const cb = new URL(authUrl.searchParams.get('cb')!); expect(cb.href).toMatch(/^https:\/\/api\.test\/connections\/lastfm\/callback\?state=/); const done = await app().inject({ url: `/connections/lastfm/callback?state=${stateFrom(cb.href)}&token=lfm-token` }); expect(done.statusCode).toBe(302); expect(done.headers.location).toBe('musicshare://settings?connected=lastfm'); const params = new URL(sessionUrl).searchParams; expect(params.get('api_sig')).toBe( lastfmSignature({ api_key: 'test-lastfm-key', method: 'auth.getSession', token: 'lfm-token' }, 'test-lastfm-secret'), ); const [row] = await db.select().from(schema.connections).where(eq(schema.connections.userId, user.id)); expect(row).toMatchObject({ provider: 'lastfm', externalUserId: 'RJ' }); expect(decrypt(row!.accessTokenEnc!)).toBe('sk-123'); }); it('returns to the app with an error if the user cancels', async () => { const { token } = await createUser(app(), 'alice'); const start = await app().inject({ method: 'POST', url: '/connections/lastfm/authorize', headers: bearer(token), payload: { client: 'web' } }); const state = stateFrom(new URL(start.json().url).searchParams.get('cb')!); const done = await app().inject({ url: `/connections/lastfm/callback?state=${state}` }); expect(done.headers.location).toMatch(/^http:\/\/web\.test\/settings\?error=/); }); it('rejects replayed or forged states', async () => { expect((await app().inject({ url: '/connections/lastfm/callback?state=forged&token=x' })).statusCode).toBe(400); }); }); describe('Spotify sign-in during the limited beta', () => { it('sends users who are not on the allowlist back with a friendly error instead of failing', async () => { vi.stubGlobal( 'fetch', routeFetch([ [/accounts\.spotify\.com\/api\/token/, () => json({ access_token: 'a', refresh_token: 'r', expires_in: 3600 })], [/api\.spotify\.com\/v1\/me$/, () => json({ error: { status: 403, message: 'User not registered in the Developer Dashboard' } }, 403)], ]), ); const { token } = await createUser(app(), 'alice'); const start = await app().inject({ method: 'POST', url: '/connections/spotify/authorize', headers: bearer(token), payload: { client: 'mobile', returnTo: 'musicshare://settings' }, }); const state = new URL(start.json().url).searchParams.get('state'); const done = await app().inject({ url: `/connections/spotify/callback?state=${state}&code=abc` }); expect(done.statusCode).toBe(302); expect(done.headers.location).toBe('musicshare://settings?error=spotify_limited'); expect(await db.select().from(schema.connections)).toHaveLength(0); }); });