pad.libre-service.eu-etherpad/src/tests/backend/common.js
2022-03-16 06:10:28 -04:00

256 lines
8.4 KiB
JavaScript

'use strict';
const AttributePool = require('../../static/js/AttributePool');
const apiHandler = require('../../node/handler/APIHandler');
const assert = require('assert').strict;
const io = require('socket.io-client');
const log4js = require('log4js');
const {padutils} = require('../../static/js/pad_utils');
const process = require('process');
const server = require('../../node/server');
const setCookieParser = require('set-cookie-parser');
const settings = require('../../node/utils/Settings');
const supertest = require('supertest');
const webaccess = require('../../node/hooks/express/webaccess');
const backups = {};
let agentPromise = null;
exports.apiKey = apiHandler.exportedForTestingOnly.apiKey;
exports.agent = null;
exports.baseUrl = null;
exports.httpServer = null;
exports.logger = log4js.getLogger('test');
const logger = exports.logger;
const logLevel = logger.level;
// Mocha doesn't monitor unhandled Promise rejections, so convert them to uncaught exceptions.
// https://github.com/mochajs/mocha/issues/2640
process.on('unhandledRejection', (reason, promise) => { throw reason; });
before(async function () {
this.timeout(60000);
await exports.init();
});
exports.init = async function () {
if (agentPromise != null) return await agentPromise;
let agentResolve;
agentPromise = new Promise((resolve) => { agentResolve = resolve; });
if (!logLevel.isLessThanOrEqualTo(log4js.levels.DEBUG)) {
logger.warn('Disabling non-test logging for the duration of the test. ' +
'To enable non-test logging, change the loglevel setting to DEBUG.');
log4js.setGlobalLogLevel(log4js.levels.OFF);
logger.setLevel(logLevel);
}
// Note: This is only a shallow backup.
backups.settings = Object.assign({}, settings);
// Start the Etherpad server on a random unused port.
settings.port = 0;
settings.ip = 'localhost';
settings.importExportRateLimiting = {max: 0};
settings.commitRateLimiting = {duration: 0.001, points: 1e6};
exports.httpServer = await server.start();
exports.baseUrl = `http://localhost:${exports.httpServer.address().port}`;
logger.debug(`HTTP server at ${exports.baseUrl}`);
// Create a supertest user agent for the HTTP server.
exports.agent = supertest(exports.baseUrl);
// Speed up authn tests.
backups.authnFailureDelayMs = webaccess.authnFailureDelayMs;
webaccess.authnFailureDelayMs = 0;
after(async function () {
webaccess.authnFailureDelayMs = backups.authnFailureDelayMs;
// Note: This does not unset settings that were added.
Object.assign(settings, backups.settings);
log4js.setGlobalLogLevel(logLevel);
await server.exit();
});
agentResolve(exports.agent);
return exports.agent;
};
/**
* Waits for the next named socket.io event. Rejects if there is an error event while waiting
* (unless waiting for that error event).
*
* @param {io.Socket} socket - The socket.io Socket object to listen on.
* @param {string} event - The socket.io Socket event to listen for.
* @returns The argument(s) passed to the event handler.
*/
exports.waitForSocketEvent = async (socket, event) => {
const errorEvents = [
'error',
'connect_error',
'connect_timeout',
'reconnect_error',
'reconnect_failed',
];
const handlers = new Map();
let cancelTimeout;
try {
const timeoutP = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`timed out waiting for ${event} event`));
cancelTimeout = () => {};
}, 1000);
cancelTimeout = () => {
clearTimeout(timeout);
resolve();
cancelTimeout = () => {};
};
});
const errorEventP = Promise.race(errorEvents.map((event) => new Promise((resolve, reject) => {
handlers.set(event, (errorString) => {
logger.debug(`socket.io ${event} event: ${errorString}`);
reject(new Error(errorString));
});
})));
const eventP = new Promise((resolve) => {
// This will overwrite one of the above handlers if the user is waiting for an error event.
handlers.set(event, (...args) => {
logger.debug(`socket.io ${event} event`);
if (args.length > 1) return resolve(args);
resolve(args[0]);
});
});
for (const [event, handler] of handlers) socket.on(event, handler);
// timeoutP and errorEventP are guaranteed to never resolve here (they can only reject), so the
// Promise returned by Promise.race() is guaranteed to resolve to the eventP value (if
// the event arrives).
return await Promise.race([timeoutP, errorEventP, eventP]);
} finally {
cancelTimeout();
for (const [event, handler] of handlers) socket.off(event, handler);
}
};
/**
* Establishes a new socket.io connection.
*
* @param {object} [res] - Optional HTTP response object. The cookies from this response's
* `set-cookie` header(s) are passed to the server when opening the socket.io connection. If
* nullish, no cookies are passed to the server.
* @returns {io.Socket} A socket.io client Socket object.
*/
exports.connect = async (res = null) => {
// Convert the `set-cookie` header(s) into a `cookie` header.
const resCookies = (res == null) ? {} : setCookieParser.parse(res, {map: true});
const reqCookieHdr = Object.entries(resCookies).map(
([name, cookie]) => `${name}=${encodeURIComponent(cookie.value)}`).join('; ');
logger.debug('socket.io connecting...');
let padId = null;
if (res) {
padId = res.req.path.split('/p/')[1];
}
const socket = io(`${exports.baseUrl}/`, {
forceNew: true, // Different tests will have different query parameters.
path: '/socket.io',
// socketio.js-client on node.js doesn't support cookies (see https://git.io/JU8u9), so the
// express_sid cookie must be passed as a query parameter.
query: {cookie: reqCookieHdr, padId},
});
try {
await exports.waitForSocketEvent(socket, 'connect');
} catch (e) {
socket.close();
throw e;
}
logger.debug('socket.io connected');
return socket;
};
/**
* Helper function to exchange CLIENT_READY+CLIENT_VARS messages for the named pad.
*
* @param {io.Socket} socket - Connected socket.io Socket object.
* @param {string} padId - Which pad to join.
* @returns The CLIENT_VARS message from the server.
*/
exports.handshake = async (socket, padId, token = padutils.generateAuthorToken()) => {
logger.debug('sending CLIENT_READY...');
socket.send({
component: 'pad',
type: 'CLIENT_READY',
padId,
sessionID: null,
token,
});
logger.debug('waiting for CLIENT_VARS response...');
const msg = await exports.waitForSocketEvent(socket, 'message');
logger.debug('received CLIENT_VARS message');
return msg;
};
/**
* Convenience wrapper around `socket.send()` that waits for acknowledgement.
*/
exports.sendMessage = async (socket, message) => await new Promise((resolve, reject) => {
socket.send(message, (errInfo) => {
if (errInfo != null) {
const {name, message} = errInfo;
const err = new Error(message);
err.name = name;
reject(err);
return;
}
resolve();
});
});
/**
* Convenience function to send a USER_CHANGES message. Waits for acknowledgement.
*/
exports.sendUserChanges = async (socket, data) => await exports.sendMessage(socket, {
type: 'COLLABROOM',
component: 'pad',
data: {
type: 'USER_CHANGES',
apool: new AttributePool(),
...data,
},
});
/**
* Convenience function that waits for an ACCEPT_COMMIT message. Asserts that the new revision
* matches the expected revision.
*
* Note: To avoid a race condition, this should be called before the USER_CHANGES message is sent.
* For example:
*
* await Promise.all([
* common.waitForAcceptCommit(socket, rev + 1),
* common.sendUserChanges(socket, {baseRev: rev, changeset}),
* ]);
*/
exports.waitForAcceptCommit = async (socket, wantRev) => {
const msg = await exports.waitForSocketEvent(socket, 'message');
assert.deepEqual(msg, {
type: 'COLLABROOM',
data: {
type: 'ACCEPT_COMMIT',
newRev: wantRev,
},
});
};
const alphabet = 'abcdefghijklmnopqrstuvwxyz';
/**
* Generates a random string.
*
* @param {number} [len] - The desired length of the generated string.
* @param {string} [charset] - Characters to pick from.
* @returns {string}
*/
exports.randomString = (len = 10, charset = `${alphabet}${alphabet.toUpperCase()}0123456789`) => {
let ret = '';
while (ret.length < len) ret += charset[Math.floor(Math.random() * charset.length)];
return ret;
};