Add server-side Google Cast discovery and sessions, signed cast media URLs, browser device picker integration, and deployment documentation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
302 lines
14 KiB
JavaScript
302 lines
14 KiB
JavaScript
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(`
|
|
<button id="player-cast" title="Play on a device" aria-label="Play on a device"></button>
|
|
<button id="watch-cast" title="Play on a device" aria-label="Play on a device"></button>
|
|
`, { 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
|
|
);
|
|
}
|
|
});
|