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() }) })