diff --git a/server/test/stream.test.ts b/server/test/stream.test.ts new file mode 100644 index 0000000..cb980c4 --- /dev/null +++ b/server/test/stream.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect } from 'vitest' +import { openDatabase } from '../src/db.js' +import { buildTestAppWithDb, setupAdmin, auth } from './helpers.js' + +const PASSWORD = 's3cret-pass-xyz' +const AUDIO = new TextEncoder().encode('fake-audio-payload') + +interface UpstreamRequest { + url: URL + range: string | null +} + +interface StubOptions { + album?: () => Response + stream?: (req: UpstreamRequest) => Response + onStream?: (req: UpstreamRequest) => void +} + +/** Subsonic stub: ping/getAlbumList2 always ok (settings PUT + sync); album/stream configurable. */ +function subsonicStub(opts: StubOptions): typeof fetch { + return (async (input: any, init?: any) => { + const url = new URL(String(input)) + if (url.pathname.endsWith('/rest/ping')) { + return new Response(JSON.stringify({ 'subsonic-response': { status: 'ok' } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + if (url.pathname.endsWith('/rest/getAlbumList2')) { + return new Response( + JSON.stringify({ 'subsonic-response': { status: 'ok', albumList2: { album: [] } } }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + } + if (url.pathname.endsWith('/rest/getAlbum')) { + return opts.album ? opts.album() : new Response('nope', { status: 404 }) + } + if (url.pathname.endsWith('/rest/stream')) { + const req: UpstreamRequest = { url, range: init?.headers?.Range ?? null } + opts.onStream?.(req) + return opts.stream ? opts.stream(req) : new Response('nope', { status: 404 }) + } + return new Response('nope', { status: 404 }) + }) as typeof fetch +} + +async function appWithSubsonic(fetchImpl: typeof fetch) { + const app = await buildTestAppWithDb(openDatabase(':memory:'), fetchImpl) + const cookie = await setupAdmin(app) + await app.inject({ + method: 'PUT', + url: '/api/settings', + ...auth(cookie), + payload: { subsonicUrl: 'http://n.local', subsonicUsername: 'sam', subsonicPassword: PASSWORD }, + }) + return { app, cookie } +} + +// Note: the played endpoint is covered in lastplayed.test.ts — this file +// focuses on the stream/album proxy behaviour. +describe('stream/album proxy routes', () => { + it('forwards the Range header to the stream upstream', async () => { + const seen: UpstreamRequest[] = [] + const { app, cookie } = await appWithSubsonic( + subsonicStub({ + onStream: (req) => { + seen.push(req) + }, + stream: () => + new Response(AUDIO, { status: 200, headers: { 'content-type': 'audio/mpeg' } }), + }) + ) + const res = await app.inject({ + method: 'GET', + url: '/api/stream/song-1', + ...auth(cookie), + headers: { range: 'bytes=100-' }, + }) + expect(res.statusCode).toBe(200) + expect(seen[0]?.range).toBe('bytes=100-') + expect(res.body).toBe('fake-audio-payload') + await app.close() + }) + + it('passes through an upstream 206 partial response with content-range', async () => { + const { app, cookie } = await appWithSubsonic( + subsonicStub({ + stream: () => + new Response(AUDIO, { + status: 206, + headers: { 'content-type': 'audio/mpeg', 'content-range': 'bytes 100-116/500' }, + }), + }) + ) + const res = await app.inject({ + method: 'GET', + url: '/api/stream/song-1', + ...auth(cookie), + headers: { range: 'bytes=100-' }, + }) + expect(res.statusCode).toBe(206) + expect(res.headers['content-type']).toBe('audio/mpeg') + expect(res.headers['content-range']).toBe('bytes 100-116/500') + expect(res.body).toBe('fake-audio-payload') + await app.close() + }) + + it('returns 502 stream_unavailable when the upstream fetch throws', async () => { + const { app, cookie } = await appWithSubsonic( + subsonicStub({ + stream: () => { + throw new Error('upstream down') + }, + }) + ) + const res = await app.inject({ method: 'GET', url: '/api/stream/song-1', ...auth(cookie) }) + expect(res.statusCode).toBe(502) + expect(res.json()).toEqual({ error: 'stream_unavailable' }) + await app.close() + }) + + it('leaks no subsonic credentials in body or headers (token auth upstream)', async () => { + const upstreamUrls: URL[] = [] + const { app, cookie } = await appWithSubsonic( + subsonicStub({ + onStream: (req) => { + upstreamUrls.push(req.url) + }, + stream: () => + new Response(AUDIO, { status: 200, headers: { 'content-type': 'audio/mpeg' } }), + }) + ) + const res = await app.inject({ method: 'GET', url: '/api/stream/song-1', ...auth(cookie) }) + expect(res.statusCode).toBe(200) + expect(res.body).not.toContain(PASSWORD) + expect(JSON.stringify(res.headers)).not.toContain(PASSWORD) + // upstream request used token auth — raw password never sent + const upstreamUrl = upstreamUrls[0] + expect(upstreamUrl?.searchParams.get('u')).toBe('sam') + expect(upstreamUrl?.searchParams.get('t')).toMatch(/^[0-9a-f]{32}$/) + expect(upstreamUrl?.searchParams.has('p')).toBe(false) + expect(upstreamUrl?.toString()).not.toContain(PASSWORD) + await app.close() + }) + + it('album tracks endpoint returns the getAlbum payload with ordered tracks', async () => { + const { app, cookie } = await appWithSubsonic( + subsonicStub({ + album: () => + new Response( + JSON.stringify({ + 'subsonic-response': { + status: 'ok', + album: { + id: 'alb-1', + name: 'Motion', + artist: 'The Cinematic Orchestra', + song: [ + { id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 }, + { id: 's1', title: 'Overture', duration: 200, track: 1 }, + ], + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ), + }) + ) + const res = await app.inject({ method: 'GET', url: '/api/album/alb-1/tracks', ...auth(cookie) }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ + id: 'alb-1', + title: 'Motion', + artist: 'The Cinematic Orchestra', + tracks: [ + { id: 's1', title: 'Overture', duration: 200, track: 1 }, + { id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 }, + ], + }) + await app.close() + }) + + it('returns 502 subsonic_error when subsonic rejects the credentials', async () => { + const { app, cookie } = await appWithSubsonic( + subsonicStub({ + album: () => + new Response( + JSON.stringify({ + 'subsonic-response': { + status: 'failed', + error: { code: 40, message: 'Wrong username or password' }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ), + }) + ) + const res = await app.inject({ method: 'GET', url: '/api/album/alb-1/tracks', ...auth(cookie) }) + expect(res.statusCode).toBe(502) + expect(res.json().error).toBe('subsonic_error') + await app.close() + }) +})