Release v2.12.0
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user