1
0
Files
record-shop/server/test/stream.test.ts

204 lines
7.0 KiB
TypeScript
Raw Normal View History

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