test: permanent coverage for stream/album proxy routes
This commit is contained in:
203
server/test/stream.test.ts
Normal file
203
server/test/stream.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user