- Shared: link building, feed ordering, input schemas, API client - API unit: track matching, private-address blocking, token crypto, plan expiry, provider response parsing - API integration against Postgres and Redis: auth, follows and pins, now-playing state, webhooks, OAuth return URLs, realtime, rate limits - Tests use a dedicated *_test database and Redis DB, and refuse to wipe anything else; fetch is stubbed so they never hit the network - Pin Node to 22.13+ (.nvmrc), which Vite's native bindings require Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
212 lines
9.8 KiB
TypeScript
212 lines
9.8 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[]) {
|
|
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 }] },
|
|
});
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|