Files
pulse-signage/test/player-local-control.test.js
lzstealth 222162df57
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m50s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s
Fix player cached images and blank time dates
2026-09-13 23:56:08 +01:00

224 lines
9.4 KiB
JavaScript

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