Files
musicshare/apps/api/test/connections.test.ts
T
chrisandClaude Opus 5.5 63d618f0e3 Unlock everything, add a donation link, credit Last.fm, nudge re-linking
- Every feature is free for everyone (the plan system stays for later);
  remaining limits are abuse guards
- Optional website donation link (NEXT_PUBLIC_DONATION_URL). Not shown in
  the mobile apps, where tips to the developer generally need in-app
  purchase
- Last.fm/ListenBrainz-sourced plays link to the user's profile, as
  Last.fm's API terms require
- Connections track when they last saw activity; a scrobbler silent for
  over a week prompts re-linking Spotify (its link expires every 6 months)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-04 08:13:54 -04:00

245 lines
11 KiB
TypeScript

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('lets everyone connect several services, and reconnecting never counts twice', 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 fourth = await app().inject({ method: 'POST', url: '/connections/listenbrainz', headers: bearer(token), payload: { username: 'x' } });
expect(fourth.statusCode).toBe(200);
});
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)");
});
});