Servers advertise many addresses (public, relay, LAN, Docker bridges). Use the last one that worked and race the rest only if it fails. Friends' servers that keep failing back off exponentially instead of slowing every poll. Errors now name each server and the underlying cause, e.g. "blackcoffee: ECONNREFUSED", instead of undici's generic "fetch failed". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
245 lines
11 KiB
TypeScript
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('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)");
|
|
});
|
|
});
|