const test = require('node:test'); const assert = require('node:assert/strict'); const express = require('express'); const fs = require('fs'); const http = require('http'); const os = require('os'); const path = require('path'); const { WebSocket } = require('ws'); require('../src/common'); const { hashPassword } = require('../src/auth'); const { createLocalControlService } = require('../src/player/local-control'); async function createTestService(options) { const temporaryDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-local-control-')); const app = express(); app.use(express.json()); const server = http.createServer(app); const service = createLocalControlService(Object.assign({ app: app, server: server, playerRuntime: { snapshotSlugs() { return ['screen-a']; }, snapshotConnections() { return [{ id: 'client-a', clientName: 'Lobby', currentSlideTitle: 'Welcome' }]; }, async sendCommandToConnection() { return 1; } }, playerIdentifier: 'test-player', cachePath: path.join(temporaryDirectory, 'users.json') }, options || {})); return { app, server, service, temporaryDirectory }; } test('local control leaves cached player media available to the static media route', async () => { const { app, server, temporaryDirectory } = await createTestService(); const cachedImagePath = path.join(temporaryDirectory, 'player-cache', 'remote-images', 'cached-image.png'); await fs.promises.mkdir(path.dirname(cachedImagePath), { recursive: true }); await fs.promises.writeFile(cachedImagePath, 'cached-image'); app.use('/media', express.static(temporaryDirectory)); const listener = app.listen(0); try { const address = listener.address(); const response = await fetch(`http://127.0.0.1:${address.port}/media/player-cache/remote-images/cached-image.png`); assert.equal(response.status, 200); assert.equal(await response.text(), 'cached-image'); } finally { await new Promise((resolve) => listener.close(resolve)); server.close(); await fs.promises.rm(temporaryDirectory, { recursive: true, force: true }); } }); test('local control authenticates only cached eligible users and scopes commands to local runtime', async () => { const { app, service, temporaryDirectory } = await createTestService(); const password = hashPassword('CorrectHorseBatteryStaple!'); await service.saveUsers([{ id: 7, name: 'Operator', username: 'operator', email: 'operator@example.com', password_hash: password.hash, password_salt: password.salt, password_iterations: password.iterations }]); await service.loadCache(); const cachedUsers = JSON.parse(await fs.promises.readFile(path.join(temporaryDirectory, 'users.json'), 'utf8')).users; assert.equal(cachedUsers[0].id, undefined); assert.equal(cachedUsers[0].name, undefined); assert.equal(cachedUsers[0].email, undefined); assert.equal(cachedUsers[0].username, undefined); assert.equal(cachedUsers[0].password_iterations, undefined); const server = app.listen(0); try { const address = server.address(); const login = await fetch(`http://127.0.0.1:${address.port}/local-control/api/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'operator', password: 'CorrectHorseBatteryStaple!' }) }); assert.equal(login.status, 200); const setCookie = login.headers.get('set-cookie'); assert.match(setCookie, /^pulse_local_control_test-player_session=/); const cookie = setCookie.split(';')[0]; const state = await fetch(`http://127.0.0.1:${address.port}/local-control/api/state`, { headers: { cookie: cookie } }); assert.equal(state.status, 200); const stateBody = await state.json(); assert.equal(stateBody.screens[0].name, 'screen-a'); assert.deepEqual(stateBody.screens[0].connections[0].clientName, 'Lobby'); assert.equal(stateBody.screens[0].connections[0].currentSlideTitle, 'Welcome'); const page = await fetch(`http://127.0.0.1:${address.port}/local-control`); const pageBody = await page.text(); assert.ok(pageBody.indexOf('ClientScreenCurrent SlideActions') !== -1); assert.ok(pageBody.indexOf('@media (max-width: 600px)') !== -1); assert.equal(pageBody.indexOf('Status'), -1); const command = await fetch(`http://127.0.0.1:${address.port}/local-control/api/commands`, { method: 'POST', headers: { cookie: cookie, 'content-type': 'application/json' }, body: JSON.stringify({ slug: 'screen-a', connectionId: 'client-a', command: 'reload' }) }); assert.equal(command.status, 200); const invalidLogin = await fetch(`http://127.0.0.1:${address.port}/local-control/api/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'other-user', password: 'CorrectHorseBatteryStaple!' }) }); assert.equal(invalidLogin.status, 401); const emailLogin = await fetch(`http://127.0.0.1:${address.port}/local-control/api/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'operator@example.com', password: 'CorrectHorseBatteryStaple!' }) }); assert.equal(emailLogin.status, 401); } finally { await new Promise(function (resolve) { server.close(resolve); }); await fs.promises.rm(temporaryDirectory, { recursive: true, force: true }); } }); test('local control refuses login when the cached authorization is stale', async () => { const { app, service, temporaryDirectory } = await createTestService({ cacheMaxAgeMs: 1 }); const password = hashPassword('CorrectHorseBatteryStaple!'); await service.saveUsers([{ id: 7, name: 'Operator', username: 'operator', password_hash: password.hash, password_salt: password.salt, password_iterations: password.iterations }]); await service.loadCache(); await new Promise(function (resolve) { setTimeout(resolve, 5); }); const server = app.listen(0); try { const address = server.address(); const response = await fetch(`http://127.0.0.1:${address.port}/local-control/api/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'operator', password: 'CorrectHorseBatteryStaple!' }) }); assert.equal(response.status, 503); } finally { await new Promise(function (resolve) { server.close(resolve); }); await fs.promises.rm(temporaryDirectory, { recursive: true, force: true }); } }); test('local control throttles repeated failed logins', async () => { const { app, service, temporaryDirectory } = await createTestService({ loginRateLimitMaxAttempts: 2, loginRateLimitWindowMs: 60 * 1000 }); const password = hashPassword('CorrectHorseBatteryStaple!'); await service.saveUsers([{ username: 'operator', password_hash: password.hash, password_salt: password.salt }]); await service.loadCache(); const server = app.listen(0); try { const address = server.address(); const login = function (passwordValue) { return fetch(`http://127.0.0.1:${address.port}/local-control/api/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'operator', password: passwordValue }) }); }; assert.equal((await login('wrong-password')).status, 401); assert.equal((await login('wrong-password')).status, 401); const blocked = await login('CorrectHorseBatteryStaple!'); assert.equal(blocked.status, 429); assert.equal(blocked.headers.get('retry-after') !== null, true); } finally { await new Promise(function (resolve) { server.close(resolve); }); await fs.promises.rm(temporaryDirectory, { recursive: true, force: true }); } }); test('local control closes live sockets when authorization is revoked', async () => { const { server, service, temporaryDirectory } = await createTestService(); const password = hashPassword('CorrectHorseBatteryStaple!'); await service.saveUsers([{ username: 'operator', password_hash: password.hash, password_salt: password.salt }]); await service.loadCache(); await new Promise(function (resolve) { server.listen(0, resolve); }); let socket; try { const address = server.address(); const login = await fetch(`http://127.0.0.1:${address.port}/local-control/api/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'operator', password: 'CorrectHorseBatteryStaple!' }) }); const cookie = login.headers.get('set-cookie').split(';')[0]; socket = new WebSocket(`ws://127.0.0.1:${address.port}/local-control/ws`, { headers: { Cookie: cookie } }); await new Promise(function (resolve, reject) { socket.once('open', resolve); socket.once('error', reject); }); const closed = new Promise(function (resolve) { socket.once('close', resolve); }); await service.saveUsers([]); await Promise.race([ closed, new Promise(function (_resolve, reject) { setTimeout(function () { reject(new Error('WebSocket was not closed after authorization revocation.')); }, 1000); }) ]); } finally { if (socket && socket.readyState !== WebSocket.CLOSED) { socket.close(); } await new Promise(function (resolve) { server.close(resolve); }); await fs.promises.rm(temporaryDirectory, { recursive: true, force: true }); } });