diff --git a/src/tests/backend/common.js b/src/tests/backend/common.js index e2dd541e2..12b5eb456 100644 --- a/src/tests/backend/common.js +++ b/src/tests/backend/common.js @@ -1,9 +1,11 @@ 'use strict'; const apiHandler = require('../../node/handler/APIHandler'); +const io = require('socket.io-client'); const log4js = require('log4js'); 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'); @@ -68,3 +70,101 @@ exports.init = async function () { 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.getSocketEvent = async (socket, event) => { + const errorEvents = [ + 'error', + 'connect_error', + 'connect_timeout', + 'reconnect_error', + 'reconnect_failed', + ]; + const handlers = {}; + let timeoutId; + return new Promise((resolve, reject) => { + timeoutId = setTimeout(() => reject(new Error(`timed out waiting for ${event} event`)), 1000); + for (const event of errorEvents) { + handlers[event] = (errorString) => { + logger.debug(`socket.io ${event} event: ${errorString}`); + reject(new Error(errorString)); + }; + } + // This will overwrite one of the above handlers if the user is waiting for an error event. + handlers[event] = (...args) => { + logger.debug(`socket.io ${event} event`); + if (args.length > 1) return resolve(args); + resolve(args[0]); + }; + Object.entries(handlers).forEach(([event, handler]) => socket.on(event, handler)); + }).finally(() => { + clearTimeout(timeoutId); + Object.entries(handlers).forEach(([event, handler]) => 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.getSocketEvent(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) => { + logger.debug('sending CLIENT_READY...'); + socket.send({ + component: 'pad', + type: 'CLIENT_READY', + padId, + sessionID: null, + token: 't.12345', + }); + logger.debug('waiting for CLIENT_VARS response...'); + const msg = await exports.getSocketEvent(socket, 'message'); + logger.debug('received CLIENT_VARS message'); + return msg; +}; diff --git a/src/tests/backend/specs/socketio.js b/src/tests/backend/specs/socketio.js index f10f63004..15f561774 100644 --- a/src/tests/backend/specs/socketio.js +++ b/src/tests/backend/specs/socketio.js @@ -2,97 +2,12 @@ const assert = require('assert').strict; const common = require('../common'); -const io = require('socket.io-client'); const padManager = require('../../../node/db/PadManager'); const plugins = require('../../../static/js/pluginfw/plugin_defs'); const readOnlyManager = require('../../../node/db/ReadOnlyManager'); -const setCookieParser = require('set-cookie-parser'); const settings = require('../../../node/utils/Settings'); const socketIoRouter = require('../../../node/handler/SocketIORouter'); -const logger = common.logger; - -// Waits for and returns the next named socket.io event. Rejects if there is any error while waiting -// (unless waiting for that error event). -const getSocketEvent = async (socket, event) => { - const errorEvents = [ - 'error', - 'connect_error', - 'connect_timeout', - 'reconnect_error', - 'reconnect_failed', - ]; - const handlers = {}; - let timeoutId; - return new Promise((resolve, reject) => { - timeoutId = setTimeout(() => reject(new Error(`timed out waiting for ${event} event`)), 1000); - for (const event of errorEvents) { - handlers[event] = (errorString) => { - logger.debug(`socket.io ${event} event: ${errorString}`); - reject(new Error(errorString)); - }; - } - // This will overwrite one of the above handlers if the user is waiting for an error event. - handlers[event] = (...args) => { - logger.debug(`socket.io ${event} event`); - if (args.length > 1) return resolve(args); - resolve(args[0]); - }; - Object.entries(handlers).forEach(([event, handler]) => socket.on(event, handler)); - }).finally(() => { - clearTimeout(timeoutId); - Object.entries(handlers).forEach(([event, handler]) => socket.off(event, handler)); - }); -}; - -// Establishes a new socket.io connection. Passes the cookies from the `set-cookie` header(s) in -// `res` (which may be nullish) to the server. Returns a socket.io Socket object. -const connect = async (res) => { - // 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(`${common.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 getSocketEvent(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. -// Returns the CLIENT_VARS message from the server. -const handshake = async (socket, padID) => { - logger.debug('sending CLIENT_READY...'); - socket.send({ - component: 'pad', - type: 'CLIENT_READY', - padId: padID, - sessionID: null, - token: 't.12345', - }); - logger.debug('waiting for CLIENT_VARS response...'); - const msg = await getSocketEvent(socket, 'message'); - logger.debug('received CLIENT_VARS message'); - return msg; -}; - describe(__filename, function () { this.timeout(30000); let agent; @@ -143,26 +58,26 @@ describe(__filename, function () { describe('Normal accesses', function () { it('!authn anonymous cookie /p/pad -> 200, ok', async function () { const res = await agent.get('/p/pad').expect(200); - socket = await connect(res); - const clientVars = await handshake(socket, 'pad'); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); }); it('!authn !cookie -> ok', async function () { - socket = await connect(null); - const clientVars = await handshake(socket, 'pad'); + socket = await common.connect(null); + const clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); }); it('!authn user /p/pad -> 200, ok', async function () { const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const clientVars = await handshake(socket, 'pad'); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); }); it('authn user /p/pad -> 200, ok', async function () { settings.requireAuthentication = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const clientVars = await handshake(socket, 'pad'); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); }); @@ -176,16 +91,16 @@ describe(__filename, function () { }; settings.requireAuthentication = authn; let res = await get('/p/pad'); - socket = await connect(res); - let clientVars = await handshake(socket, 'pad'); + socket = await common.connect(res); + let clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, false); const readOnlyId = clientVars.data.readOnlyId; assert(readOnlyManager.isReadOnlyId(readOnlyId)); socket.close(); res = await get(`/p/${readOnlyId}`); - socket = await connect(res); - clientVars = await handshake(socket, readOnlyId); + socket = await common.connect(res); + clientVars = await common.handshake(socket, readOnlyId); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, true); }); @@ -195,8 +110,8 @@ describe(__filename, function () { settings.requireAuthentication = true; settings.requireAuthorization = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const clientVars = await handshake(socket, 'pad'); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); }); it('supports pad names with characters that must be percent-encoded', async function () { @@ -208,8 +123,8 @@ describe(__filename, function () { settings.requireAuthorization = true; const encodedPadId = encodeURIComponent('päd'); const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200); - socket = await connect(res); - const clientVars = await handshake(socket, 'päd'); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'päd'); assert.equal(clientVars.type, 'CLIENT_VARS'); }); }); @@ -219,31 +134,31 @@ describe(__filename, function () { settings.requireAuthentication = true; const res = await agent.get('/p/pad').expect(401); // Despite the 401, try to create the pad via a socket.io connection anyway. - socket = await connect(res); - const message = await handshake(socket, 'pad'); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it('authn anonymous read-only /p/pad -> 401, error', async function () { settings.requireAuthentication = true; let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const clientVars = await handshake(socket, 'pad'); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); const readOnlyId = clientVars.data.readOnlyId; assert(readOnlyManager.isReadOnlyId(readOnlyId)); socket.close(); res = await agent.get(`/p/${readOnlyId}`).expect(401); // Despite the 401, try to read the pad via a socket.io connection anyway. - socket = await connect(res); - const message = await handshake(socket, readOnlyId); + socket = await common.connect(res); + const message = await common.handshake(socket, readOnlyId); assert.equal(message.accessStatus, 'deny'); }); it('authn !cookie -> error', async function () { settings.requireAuthentication = true; - socket = await connect(null); - const message = await handshake(socket, 'pad'); + socket = await common.connect(null); + const message = await common.handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it('authorization bypass attempt -> error', async function () { @@ -253,9 +168,9 @@ describe(__filename, function () { settings.requireAuthorization = true; // First authenticate and establish a session. const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); + socket = await common.connect(res); // Accessing /p/other-pad should fail, despite the successful fetch of /p/pad. - const message = await handshake(socket, 'other-pad'); + const message = await common.handshake(socket, 'other-pad'); assert.equal(message.accessStatus, 'deny'); }); }); @@ -269,16 +184,16 @@ describe(__filename, function () { it("level='create' -> can create", async function () { authorize = () => 'create'; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const clientVars = await handshake(socket, 'pad'); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, false); }); it('level=true -> can create', async function () { authorize = () => true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const clientVars = await handshake(socket, 'pad'); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, false); }); @@ -286,8 +201,8 @@ describe(__filename, function () { await padManager.getPad('pad'); // Create the pad. authorize = () => 'modify'; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const clientVars = await handshake(socket, 'pad'); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, false); }); @@ -295,31 +210,31 @@ describe(__filename, function () { authorize = () => 'create'; settings.editOnly = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const message = await handshake(socket, 'pad'); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it("level='modify' settings.editOnly=false -> unable to create", async function () { authorize = () => 'modify'; settings.editOnly = false; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const message = await handshake(socket, 'pad'); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it("level='readOnly' -> unable to create", async function () { authorize = () => 'readOnly'; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const message = await handshake(socket, 'pad'); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it("level='readOnly' -> unable to modify", async function () { await padManager.getPad('pad'); // Create the pad. authorize = () => 'readOnly'; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const clientVars = await handshake(socket, 'pad'); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, true); }); @@ -333,39 +248,39 @@ describe(__filename, function () { it('user.canCreate = true -> can create and modify', async function () { settings.users.user.canCreate = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const clientVars = await handshake(socket, 'pad'); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, false); }); it('user.canCreate = false -> unable to create', async function () { settings.users.user.canCreate = false; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const message = await handshake(socket, 'pad'); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it('user.readOnly = true -> unable to create', async function () { settings.users.user.readOnly = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const message = await handshake(socket, 'pad'); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it('user.readOnly = true -> unable to modify', async function () { await padManager.getPad('pad'); // Create the pad. settings.users.user.readOnly = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const clientVars = await handshake(socket, 'pad'); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, true); }); it('user.readOnly = false -> can create and modify', async function () { settings.users.user.readOnly = false; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const clientVars = await handshake(socket, 'pad'); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); assert.equal(clientVars.data.readonly, false); }); @@ -373,8 +288,8 @@ describe(__filename, function () { settings.users.user.canCreate = true; settings.users.user.readOnly = true; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const message = await handshake(socket, 'pad'); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); }); @@ -389,8 +304,8 @@ describe(__filename, function () { settings.users.user.readOnly = true; authorize = () => 'create'; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const message = await handshake(socket, 'pad'); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); it('user settings does not elevate level from authorize hook', async function () { @@ -398,8 +313,8 @@ describe(__filename, function () { settings.users.user.canCreate = true; authorize = () => 'readOnly'; const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await connect(res); - const message = await handshake(socket, 'pad'); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); }); @@ -430,7 +345,7 @@ describe(__filename, function () { socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { handleConnect(socket) { serverSocket = socket; } }()); - socket = await connect(); + socket = await common.connect(); assert(serverSocket != null); }); @@ -452,7 +367,7 @@ describe(__filename, function () { resolveDisconnected(); } }()); - socket = await connect(); + socket = await common.connect(); await connected; socket.close(); socket = null; @@ -474,7 +389,7 @@ describe(__filename, function () { socketIoRouter.addComponent(`${this.test.fullTitle()} #2`, new class extends Module { handleMessage(socket, message) { assert.fail('wrong handler called'); } }()); - socket = await connect(); + socket = await common.connect(); socket.send(want); assert.deepEqual(await got, want); }); @@ -492,7 +407,7 @@ describe(__filename, function () { socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { handleMessage(socket, msg) { return want; } }()); - socket = await connect(); + socket = await common.connect(); const got = await tx(socket, {component: this.test.fullTitle()}); assert.equal(got, want); }); @@ -504,7 +419,7 @@ describe(__filename, function () { socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { handleMessage(socket, msg) { throw new InjectedError(); } }()); - socket = await connect(); + socket = await common.connect(); await assert.rejects(tx(socket, {component: this.test.fullTitle()}), new InjectedError()); }); });