224 lines
9.4 KiB
JavaScript
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 });
|
|
}
|
|
});
|