const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { JSDOM } = require('jsdom'); const root = path.resolve(__dirname, '..'); function requireExisting(relativePath) { const absolutePath = path.join(root, relativePath); assert.ok(fs.existsSync(absolutePath), `${relativePath} should exist`); delete require.cache[require.resolve(absolutePath)]; return require(absolutePath); } test('server registers authenticated cast routes for discovery, refresh and session creation', () => { const indexSource = fs.readFileSync(path.join(root, 'server', 'index.js'), 'utf8'); assert.match(indexSource, /app\.use\('\/api\/cast',\s*require\('\.\/routes\/cast'\)\)/); const routeSource = fs.readFileSync(path.join(root, 'server', 'routes', 'cast.js'), 'utf8'); assert.match(routeSource, /router\.get\('\/devices'/, 'GET /api/cast/devices should be routed'); assert.match(routeSource, /router\.post\('\/devices\/refresh'/, 'POST /api/cast/devices/refresh should be routed'); assert.match(routeSource, /router\.post\('\/sessions'/, 'POST /api/cast/sessions should be routed'); assert.match(routeSource, /requireAuth/, 'cast API should require authenticated users'); }); test('server cast session service rejects non-castable base URLs and calls the adapter with an absolute media URL', async () => { const { createCastSessionService } = requireExisting('server/services/castSessionService.js'); assert.equal(typeof createCastSessionService, 'function'); const adapterCalls = []; const adapter = { discover: async () => [ { id: 'living-room', kind: 'google_cast', name: 'Living Room TV', ready: true } ], startSession: async request => { adapterCalls.push(request); return { sessionId: 'session-1' }; } }; const localhostService = createCastSessionService({ adapter, publicBaseUrl: 'http://localhost:3000', mediaUrlSigner: { sign: pathValue => `/api/cast/media/${pathValue}?token=abc` } }); await assert.rejects( () => localhostService.startSession({ deviceId: 'living-room', mediaPath: '/api/proxy/stream?url=http%3A%2F%2Fexample.test%2Flive.m3u8' }), /LAN|castable|localhost|base URL/i ); const lanService = createCastSessionService({ adapter, publicBaseUrl: 'http://192.168.1.40:3000', mediaUrlSigner: { sign: pathValue => `/api/cast/media${pathValue}?token=abc` } }); const result = await lanService.startSession({ deviceId: 'living-room', mediaPath: '/api/transcode/session-safe/stream.m3u8' }); assert.equal(result.sessionId, 'session-1'); assert.equal(adapterCalls.length, 1); assert.equal(adapterCalls[0].deviceId, 'living-room'); assert.match(adapterCalls[0].mediaUrl, /^http:\/\/192\.168\.1\.40:3000\/api\/cast\/media/); assert.doesNotMatch(adapterCalls[0].mediaUrl, /localhost|127\.0\.0\.1/); }); test('cast media URL signer creates absolute cookie-free URLs and rejects invalid or expired tokens', () => { const { createCastMediaUrlService } = requireExisting('server/services/castMediaUrlService.js'); assert.equal(typeof createCastMediaUrlService, 'function'); let now = 1_000; const service = createCastMediaUrlService({ secret: 'test-secret-with-at-least-32-bytes-long', publicBaseUrl: 'http://192.168.1.40:3000', ttlMs: 30_000, now: () => now }); const mediaUrl = service.sign('/api/transcode/session-1/stream.m3u8'); assert.match(mediaUrl, /^http:\/\/192\.168\.1\.40:3000\/api\/cast\/media\//); assert.match(mediaUrl, /token=/); assert.doesNotMatch(mediaUrl, /Cookie|authToken|session/i); const parsed = new URL(mediaUrl); const id = parsed.pathname.split('/').at(-1); const token = parsed.searchParams.get('token'); const validated = service.validate(id, token); assert.deepEqual(validated, { path: '/api/transcode/session-1/stream.m3u8' }); assert.throws(() => service.validate(id, 'bad-token'), /invalid|token/i); assert.throws(() => service.validate('different-id', token), /invalid|token/i); now += 31_000; assert.throws(() => service.validate(id, token), /expired|token/i); }); test('cast signing requires a dedicated strong secret and supports revocation', () => { const { createCastMediaUrlService } = requireExisting('server/services/castMediaUrlService.js'); const missing = createCastMediaUrlService({ publicBaseUrl: 'http://192.168.1.40:3000', secret: '' }); assert.throws(() => missing.sign('/api/transcode/session-1/stream.m3u8'), /CAST_SIGNING_SECRET|32 bytes/i); const weak = createCastMediaUrlService({ publicBaseUrl: 'http://192.168.1.40:3000', secret: 'too-short' }); assert.throws(() => weak.sign('/api/transcode/session-1/stream.m3u8'), /CAST_SIGNING_SECRET|32 bytes/i); const service = createCastMediaUrlService({ publicBaseUrl: 'http://192.168.1.40:3000', secret: 'another-test-secret-with-32-bytes-minimum' }); const mediaUrl = service.sign('/api/transcode/session-1/stream.m3u8'); const parsed = new URL(mediaUrl); const id = parsed.pathname.split('/').at(-1); const token = parsed.searchParams.get('token'); assert.equal(service.revoke(mediaUrl), true); assert.throws(() => service.validate(id, token), /invalid|token/i); }); test('cast session validation rejects upstream URLs, unknown fields and oversized identifiers', async () => { const { createCastSessionService } = requireExisting('server/services/castSessionService.js'); let discoveries = 0; const service = createCastSessionService({ publicBaseUrl: 'http://192.168.1.40:3000', adapter: { discover: async () => { discoveries += 1; return [{ id: 'cast-1', type: 'google_cast', ready: true }]; }, startSession: async () => ({ sessionId: 'session-1' }) }, mediaUrlSigner: { sign: () => '/api/cast/media/id?token=abc' }, discoveryTtlMs: 60_000 }); await assert.rejects(() => service.startSession({ deviceId: 'cast-1', mediaPath: '/api/proxy/stream?url=http%3A%2F%2Fprovider.test%2Flive.m3u8' }), /upstream|allowed/i); await assert.rejects(() => service.startSession({ deviceId: 'cast-1', mediaPath: '/api/transcode/session-1/stream.m3u8', unexpected: true }), /unknown/i); await assert.rejects(() => service.startSession({ deviceId: 'x'.repeat(129), mediaPath: '/api/transcode/session-1/stream.m3u8' }), /deviceId/i); await service.getDevices(); await service.getDevices(); assert.equal(discoveries, 1, 'device discovery should be cached within its TTL'); }); test('cast command validation rejects malformed seek, volume and unknown fields', () => { const { validateCommandBody } = requireExisting('server/routes/cast.js'); assert.throws(() => validateCommandBody('seek', { currentTime: -1 }), /greater than or equal/i); assert.throws(() => validateCommandBody('seek', { currentTime: 5, extra: true }), /requires currentTime/i); assert.throws(() => validateCommandBody('volume', { level: 1.1 }), /between 0 and 1/i); assert.throws(() => validateCommandBody('volume', { muted: 'yes' }), /boolean/i); assert.throws(() => validateCommandBody('play', { unexpected: true }), /does not accept/i); assert.deepEqual(validateCommandBody('seek', { currentTime: 12 }), { currentTime: 12 }); assert.deepEqual(validateCommandBody('volume', { level: 0.5, muted: false }), { level: 0.5, muted: false }); }); test('Google Cast adapter discovers mDNS devices and loads media through Default Media Receiver', async () => { const { createGoogleCastAdapter } = requireExisting('server/services/googleCastAdapter.js'); const calls = []; let browserStopped = false; let bonjourDestroyed = false; class FakeBonjour { find(options, onup) { calls.push(['find', options]); onup({ name: 'Living Room', fqdn: 'living-room._googlecast._tcp.local', port: 8009, addresses: ['192.168.1.55'], txt: { id: 'cast-1', fn: 'Living Room TV', md: 'Chromecast' } }); return { on() {}, stop() { browserStopped = true; } }; } destroy() { bonjourDestroyed = true; } } const controller = { load: async payload => { calls.push(['load', payload]); return { unwrapAndThrow: () => ({ playerState: 'PLAYING', currentTime: 0 }) }; }, stop: async () => ({ unwrapAndThrow: () => ({ playerState: 'IDLE' }) }), dispose: () => calls.push(['dispose']) }; const client = { close: () => calls.push(['close']) }; const castClient = { connect: async options => { calls.push(['connect', options]); return client; }, DefaultMediaApp: { launchAndJoin: async request => { calls.push(['launch', request]); return { unwrapAndThrow: () => controller }; } } }; const adapter = createGoogleCastAdapter({ dependencies: { Bonjour: FakeBonjour, castClient }, discoveryTimeoutMs: 5 }); const devices = await adapter.discover(); assert.deepEqual(devices, [{ id: 'cast-1', name: 'Living Room TV', type: 'google_cast', ready: true, host: '192.168.1.55', port: 8009, model: 'Chromecast' }]); assert.equal(browserStopped, true); assert.equal(bonjourDestroyed, true); const session = await adapter.startSession({ device: devices[0], mediaUrl: 'http://192.168.1.40:3000/api/cast/media/id?token=signed', media: {} }); assert.equal(session.state, 'playing'); assert.deepEqual(calls.find(call => call[0] === 'connect')[1], { host: '192.168.1.55', port: 8009, timeout: 8000 }); const load = calls.find(call => call[0] === 'load')[1]; assert.equal(load.media.contentType, 'application/vnd.apple.mpegurl'); assert.equal(load.media.streamType, 'LIVE'); await adapter.stopSession(session); assert.ok(calls.some(call => call[0] === 'dispose')); assert.ok(calls.some(call => call[0] === 'close')); }); test('frontend exposes server cast API helpers and a device picker with honest Fire TV handling', async () => { const dom = new JSDOM(` `, { url: 'http://nodecast.test/', runScripts: 'outside-only' }); const requests = []; dom.window.localStorage.setItem('authToken', 'token-1'); dom.window.fetch = async (url, options = {}) => { requests.push({ url, options }); if (url === '/api/cast/devices') { return { ok: true, headers: { get: () => 'application/json' }, json: async () => ({ devices: [ { id: 'cast-1', type: 'google_cast', name: 'Chromecast', ready: true }, { id: 'atv-1', type: 'android_tv_cast', name: 'Android TV', ready: true }, { id: 'fire-1', type: 'fire_tv', name: 'Fire TV', ready: false, status: 'receiver_required' } ] }) }; } return { ok: true, headers: { get: () => 'application/json' }, json: async () => ({ sessionId: 'session-1' }) }; }; dom.window.eval(fs.readFileSync(path.join(root, 'public', 'js', 'api.js'), 'utf8')); assert.ok(dom.window.API.cast, 'API.cast should expose server-driven cast helpers'); assert.equal(typeof dom.window.API.cast.getDevices, 'function'); assert.equal(typeof dom.window.API.cast.refreshDevices, 'function'); assert.equal(typeof dom.window.API.cast.startSession, 'function'); const html = fs.readFileSync(path.join(root, 'public', 'index.html'), 'utf8'); assert.match(html, /id="cast-device-dialog"/, 'cast button should open a device picker dialog'); assert.match(html, /id="cast-device-list"/, 'cast UI should render a device list'); const { ServerCastDevicePicker } = requireExisting('public/js/services/serverCastDevicePicker.js'); const picker = new ServerCastDevicePicker({ document: dom.window.document, api: dom.window.API.cast, nativeCastService: { state: { available: true }, prompt: async () => {} }, mediaUrlProvider: () => '/api/transcode/session-safe/stream.m3u8' }); await picker.open(); assert.equal(requests[0].url, '/api/cast/devices'); assert.match(dom.window.document.body.textContent, /Chromecast/); assert.match(dom.window.document.body.textContent, /Android TV/); assert.match(dom.window.document.body.textContent, /Receiver required/); assert.match(dom.window.document.body.textContent, /Use browser device picker/); const fireButton = dom.window.document.querySelector('[data-device-id="fire-1"]'); assert.ok(fireButton.disabled, 'Fire TV without a receiver app should not be connectable'); fireButton.click(); assert.equal(requests.filter(request => request.url === '/api/cast/sessions').length, 0); dom.window.document.querySelector('[data-device-id="cast-1"]').click(); assert.equal(requests.at(-1).url, '/api/cast/sessions'); assert.equal(JSON.parse(requests.at(-1).options.body).deviceId, 'cast-1'); }); test('frontend does not create a server cast session for proxy, external or otherwise unsafe media paths', async () => { const unsafePaths = [ '/api/proxy/stream?url=http%3A%2F%2Fexample.test%2Flive.m3u8', 'https://example.test/live.m3u8', '/api/transcode/session-safe/stream.m3u8?token=unsafe' ]; for (const mediaPath of unsafePaths) { const dom = new JSDOM('', { url: 'http://nodecast.test/' }); let sessionPosts = 0; const { ServerCastDevicePicker } = requireExisting('public/js/services/serverCastDevicePicker.js'); const picker = new ServerCastDevicePicker({ document: dom.window.document, api: { getDevices: async () => ({ devices: [] }), startSession: async () => { sessionPosts += 1; } }, nativeCastService: { state: { available: true }, prompt: async () => {} }, mediaUrlProvider: () => mediaPath }); await picker.connect('cast-1'); assert.equal(sessionPosts, 0, `${mediaPath} must not create a cast session`); assert.match( dom.window.document.getElementById('cast-device-message').textContent, /not ready for server casting yet.*browser device picker/i ); } });