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