mirror of
https://github.com/ether/etherpad-lite.git
synced 2025-02-01 03:12:42 +01:00
256 lines
8.4 KiB
JavaScript
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;
|
|
};
|