Release v2.12.0
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 2m6s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s

This commit is contained in:
2026-09-11 17:22:31 +01:00
parent bdb5ab4bac
commit 0a03cdd0b7
32 changed files with 2211 additions and 18 deletions
+52
View File
@@ -0,0 +1,52 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('crypto');
const { registerPlayerControlSyncTask } = require('../src/web/lib/background-tasks/tasks-scheduled/player-control-sync');
test('player control sync sends minimized authorization data to remote players only', async () => {
let task = null;
const forwarded = [];
registerPlayerControlSyncTask({
pool: {
async query() {
return [[{
username: 'operator',
password_hash: 'hash',
password_salt: 'salt'
}]];
}
},
common: {
async fetchPlayerRegistrations() {
return [
{ identifier: 'local', internal_base_url: 'http://player:8081' },
{ identifier: 'remote', internal_base_url: 'http://remote-player:8081' }
];
}
},
backgroundTaskQueue: {
registerRecurringTask(value) {
task = value;
}
},
localPlayerInternalUrl: 'http://player:8081',
forwardPlayerCommandToDevice: async function (deviceId, payload) {
forwarded.push({ deviceId, payload });
}
});
assert.equal(task.intervalMs, 15 * 60 * 1000);
await task.run();
assert.deepEqual(forwarded, [{
deviceId: 'remote',
payload: {
command: 'sync-local-control',
users: [{
username_hash: crypto.createHash('sha256').update('operator').digest('hex'),
password_hash: 'hash',
password_salt: 'salt'
}]
}
}]);
});
+204
View File
@@ -0,0 +1,204 @@
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 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('<th>Client</th><th>Screen</th><th>Current Slide</th><th>Actions</th>') !== -1);
assert.ok(pageBody.indexOf('@media (max-width: 600px)') !== -1);
assert.equal(pageBody.indexOf('<th>Status</th>'), -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 });
}
});