import { eq } from 'drizzle-orm'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { db, schema } from '../src/db'; import { encrypt } from '../src/lib/crypto'; import { redis } from '../src/lib/redis'; import { plexProvider } from '../src/providers/plex'; import { upsertConnection } from '../src/services/connections'; import { bearer, createUser, json, routeFetch, useTestApp } from './helpers'; const app = useTestApp(); afterEach(() => vi.unstubAllGlobals()); const lastfmOk = routeFetch([[/audioscrobbler\.com.*user\.getinfo/, () => json({ user: { name: 'RJ' } })]]); describe('username connections', () => { it('connects Last.fm using the canonical username and schedules a poll', async () => { vi.stubGlobal('fetch', lastfmOk); const { token } = await createUser(app(), 'alice'); const res = await app().inject({ method: 'POST', url: '/connections/lastfm', headers: bearer(token), payload: { username: 'rj' } }); expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ provider: 'lastfm', externalUserId: 'RJ', status: 'active', webhookUrl: null }); expect(await redis.zcard('poll:due')).toBe(1); }); it('404s for unknown users', async () => { vi.stubGlobal('fetch', routeFetch([[/audioscrobbler/, () => json({ error: 6 }, 404)]])); const { token } = await createUser(app(), 'alice'); const res = await app().inject({ method: 'POST', url: '/connections/lastfm', headers: bearer(token), payload: { username: 'ghost' } }); expect(res.statusCode).toBe(404); }); it('enforces the per-plan connection limit, but reconnecting is free', async () => { vi.stubGlobal( 'fetch', routeFetch([ [/audioscrobbler/, () => json({ user: { name: 'rj' } })], [/listenbrainz/, () => json({ payload: { listens: [] } })], ]), ); const { token, user } = await createUser(app(), 'alice'); await upsertConnection({ userId: user.id, provider: 'spotify', externalUserId: 's', displayName: null }); await upsertConnection({ userId: user.id, provider: 'plex', externalUserId: 'p', displayName: null }); const ok = await app().inject({ method: 'POST', url: '/connections/lastfm', headers: bearer(token), payload: { username: 'rj' } }); expect(ok.statusCode).toBe(200); const again = await app().inject({ method: 'POST', url: '/connections/lastfm', headers: bearer(token), payload: { username: 'rj' } }); expect(again.statusCode).toBe(200); const over = await app().inject({ method: 'POST', url: '/connections/listenbrainz', headers: bearer(token), payload: { username: 'x' } }); expect(over.statusCode).toBe(402); }); it('disconnect removes the connection and its schedule', async () => { vi.stubGlobal('fetch', lastfmOk); const { token } = await createUser(app(), 'alice'); await app().inject({ method: 'POST', url: '/connections/lastfm', headers: bearer(token), payload: { username: 'rj' } }); await app().inject({ method: 'DELETE', url: '/connections/lastfm', headers: bearer(token) }); expect((await app().inject({ url: '/connections', headers: bearer(token) })).json()).toEqual([]); expect(await redis.zcard('poll:due')).toBe(0); }); }); describe('Jellyfin', () => { it('refuses private server addresses before making any request', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const { token } = await createUser(app(), 'alice'); const res = await app().inject({ method: 'POST', url: '/connections/jellyfin', headers: bearer(token), payload: { serverUrl: 'http://169.254.169.254', username: 'a', password: 'b' }, }); expect(res.statusCode).toBe(400); expect(res.json().error).toMatch(/private network/); expect(fetchMock).not.toHaveBeenCalled(); }); it('connects with credentials, stores only the token, and issues a webhook URL', async () => { const calls: { url: string; body?: string }[] = []; vi.stubGlobal( 'fetch', routeFetch([ [/System\/Info\/Public/, () => json({ ServerName: 'Home', Version: '10.11.0' })], [ /AuthenticateByName/, (url, init) => { calls.push({ url, body: init?.body as string }); return json({ AccessToken: 'jf-token', User: { Id: 'u-1', Name: 'tester' } }); }, ], ]), ); const { token } = await createUser(app(), 'alice'); const res = await app().inject({ method: 'POST', url: '/connections/jellyfin', headers: bearer(token), payload: { serverUrl: 'https://8.8.8.8/', username: 'tester', password: 'hunter2' }, }); expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ displayName: 'tester on Home', webhookUrl: expect.stringMatching(/^https:\/\/api\.test\/webhooks\/jellyfin\/.{20,}/) }); expect(JSON.parse(calls[0]!.body!)).toEqual({ Username: 'tester', Pw: 'hunter2' }); const [row] = await db.select().from(schema.connections); expect(JSON.stringify(row)).not.toContain('hunter2'); expect(row!.accessTokenEnc).not.toBe('jf-token'); }); }); describe('webhooks', () => { async function connectionWithWebhook() { const { user } = await createUser(app(), 'alice'); const conn = await upsertConnection({ userId: user.id, provider: 'jellyfin', externalUserId: 'u', displayName: null, meta: {} }); await redis.zadd('poll:due', Date.now() + 600_000, conn.id); return conn; } it.each([ ['JSON (Jellyfin plugin)', { 'content-type': 'application/json' }, '{"NotificationType":"PlaybackStart"}'], ['multipart (Plex)', { 'content-type': 'multipart/form-data; boundary=x' }, '--x\r\nContent-Disposition: form-data; name="payload"\r\n\r\n{}\r\n--x--'], ['malformed JSON', { 'content-type': 'application/json' }, '{not json'], ])('a %s webhook pulls the next poll forward', async (_label, headers, payload) => { const conn = await connectionWithWebhook(); const res = await app().inject({ method: 'POST', url: `/webhooks/jellyfin/${conn.webhookSecret}`, headers, payload }); expect(res.statusCode).toBe(204); expect(Number(await redis.zscore('poll:due', conn.id))).toBeLessThanOrEqual(Date.now()); }); it('answers unknown secrets the same way, without touching schedules', async () => { const conn = await connectionWithWebhook(); const res = await app().inject({ method: 'POST', url: '/webhooks/jellyfin/not-a-secret', payload: {} }); expect(res.statusCode).toBe(204); expect(Number(await redis.zscore('poll:due', conn.id))).toBeGreaterThan(Date.now()); }); it('keeps the webhook secret when a connection is re-established', async () => { const conn = await connectionWithWebhook(); const again = await upsertConnection({ userId: conn.userId, provider: 'jellyfin', externalUserId: 'u', displayName: null, meta: {} }); expect(again.webhookSecret).toBe(conn.webhookSecret); }); }); describe('OAuth return URLs', () => { it.each([ ['musicshare://settings', 'musicshare://settings'], ['exp://192.168.1.20:8081/--/settings', 'exp://192.168.1.20:8081/--/settings'], ['https://evil.example.com/steal', 'musicshare://settings'], ['javascript:alert(1)', 'musicshare://settings'], ])('mobile returnTo %s is stored as %s', async (requested, stored) => { vi.stubGlobal('fetch', routeFetch([[/plex\.tv\/api\/v2\/pins/, () => json({ id: 1, code: 'abc' })]])); const { token } = await createUser(app(), 'alice'); const res = await app().inject({ method: 'POST', url: '/connections/plex/authorize', headers: bearer(token), payload: { client: 'mobile', returnTo: requested }, }); const state = decodeURIComponent(res.json().url).match(/state=([^&]+)/)![1]; expect(JSON.parse((await redis.get(`oauth:state:${state}`))!).returnTo).toBe(stored); }); }); describe('Plex polling', () => { async function plexConnection(uris: string[], extraServers: object[] = []) { const { user } = await createUser(app(), 'alice'); return upsertConnection({ userId: user.id, provider: 'plex', externalUserId: '12345', displayName: null, accessTokenEnc: encrypt('account-token'), meta: { discoveredAt: Date.now(), servers: [{ name: 'Home', owned: true, tokenEnc: encrypt('server-token'), uris }, ...extraServers], }, }); } const friendServer = (name: string, uri: string) => ({ name, owned: false, tokenEnc: encrypt('t'), uris: [uri] }); const refused = () => Promise.reject(Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } })); const sessions = { MediaContainer: { Metadata: [ { type: 'movie', title: 'A Movie', User: { id: '1' }, Player: { state: 'playing' } }, { type: 'track', title: 'Theirs', grandparentTitle: 'X', User: { id: '999' }, Player: { state: 'playing' } }, { type: 'track', title: 'Digital Love', grandparentTitle: 'Daft Punk', parentTitle: 'Discovery', viewOffset: 42000, User: { id: '1' }, Player: { state: 'playing' } }, ], }, }; it("returns the owner's track, ignoring video and other users, and fails over between addresses", async () => { vi.stubGlobal( 'fetch', routeFetch([ [/^https:\/\/1\.1\.1\.1/, () => Promise.reject(new TypeError('fetch failed'))], [/^https:\/\/8\.8\.8\.8.*status\/sessions/, () => json(sessions)], ]), ); const conn = await plexConnection(['https://1.1.1.1:32400', 'https://8.8.8.8:32400']); const result = await plexProvider.poll(conn); expect(result).toEqual({ track: { title: 'Digital Love', artist: 'Daft Punk', album: 'Discovery', durationMs: null }, isPlaying: true, progressMs: 42000, }); const [row] = await db.select().from(schema.connections).where(eq(schema.connections.id, conn.id)); expect((row!.meta as { servers: { uris: string[] }[] }).servers[0]!.uris[0]).toBe('https://8.8.8.8:32400'); }); it('skips LAN-only addresses in hosted mode and says why', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const conn = await plexConnection(['http://192.168.1.10:32400']); await expect(plexProvider.poll(conn)).rejects.toThrow(/private network/); expect(fetchMock).not.toHaveBeenCalled(); }); it("backs off a friend's server that keeps failing, so it stops slowing every poll", async () => { let friendCalls = 0; vi.stubGlobal( 'fetch', routeFetch([ [/^https:\/\/8\.8\.8\.8/, () => json(sessions)], [/^https:\/\/9\.9\.9\.9/, () => (friendCalls++, refused())], ]), ); const conn = await plexConnection(['https://8.8.8.8:32400'], [friendServer('Friend', 'https://9.9.9.9:32400')]); expect((await plexProvider.poll(conn))?.track.title).toBe('Digital Love'); const [row] = await db.select().from(schema.connections).where(eq(schema.connections.id, conn.id)); await plexProvider.poll(row!); expect(friendCalls).toBe(1); // skipped on the second poll }); it("reports the owner's server being down even when friends' servers answer", async () => { vi.stubGlobal( 'fetch', routeFetch([ [/^https:\/\/8\.8\.8\.8/, () => refused()], [/^https:\/\/9\.9\.9\.9/, () => json({ MediaContainer: {} })], ]), ); const conn = await plexConnection(['https://8.8.8.8:32400'], [friendServer('Friend', 'https://9.9.9.9:32400')]); await expect(plexProvider.poll(conn)).rejects.toThrow("Couldn't reach your Plex server (Home: ECONNREFUSED)"); }); });