From e846192db0eb0524033b00a661a8cc24a2f13396 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Thu, 1 Oct 2020 13:59:55 +0100 Subject: [PATCH] [MatrixTests] add a large testing matrix Layers/Dimensions: - users: anonymous, registered, registeredWithOwnedProject - session setup: noop, joinReadWriteProject, joinReadWriteProjectAndDoc, joinOwnProject, joinOwnProjectAndDoc - invalid requests: noop, joinProjectWithDocId, joinDocWithDocId, joinProjectWithProjectId, joinDocWithProjectId, joinProjectWithProjectIdThenJoinDocWithDocId --- .../test/acceptance/js/MatrixTests.js | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 services/real-time/test/acceptance/js/MatrixTests.js diff --git a/services/real-time/test/acceptance/js/MatrixTests.js b/services/real-time/test/acceptance/js/MatrixTests.js new file mode 100644 index 0000000000..ff59c4a7ab --- /dev/null +++ b/services/real-time/test/acceptance/js/MatrixTests.js @@ -0,0 +1,478 @@ +/* +This test suite is a multi level matrix which allows us to test many cases + with all kinds of setups. + +Users/Actors are defined in USERS and are a low level entity that does connect + to a real-time pod. A typical UserItem is: + + someDescriptiveNameForTheTestSuite: { + setup(cb) { + // + const options = { client: RealTimeClient.connect(), foo: 'bar' } + cb(null, options) + } + } + +Sessions are a set of actions that a User performs in the life-cycle of a + real-time session, before they try something weird. A typical SessionItem is: + + someOtherDescriptiveNameForTheTestSuite: { + getActions(cb) { + cb(null, [ + { rpc: 'RPC_ENDPOINT', args: [...] } + ]) + } + } + +Finally there are InvalidRequests which are the weird actions I hinted on in + the Sessions section. The defined actions may be marked as 'failed' to denote + that real-time rejects them with an (for this test) expected error. + A typical InvalidRequestItem is: + + joinOwnProject: { + getActions(cb) { + cb(null, [ + { rpc: 'RPC_ENDPOINT', args: [...], failed: true } + ]) + } + } + +There is additional meta-data that UserItems and SessionItems may use to skip + certain areas of the matrix. Theses are: + +- Has the User an own project that they join as part of the Session? + UserItem: { hasOwnProject: true, setup(cb) { cb(null, { project_id, ... }) }} + SessionItem: { needsOwnProject: true } + */ +/* eslint-disable + camelcase, +*/ +const chai = require('chai') +const { expect } = chai +const async = require('async') + +const RealTimeClient = require('./helpers/RealTimeClient') +const FixturesManager = require('./helpers/FixturesManager') + +const settings = require('settings-sharelatex') +const Keys = settings.redis.documentupdater.key_schema +const redis = require('redis-sharelatex') +const rclient = redis.createClient(settings.redis.pubsub) + +function getPendingUpdates(doc_id, cb) { + rclient.lrange(Keys.pendingUpdates({ doc_id }), 0, 10, cb) +} +function cleanupPreviousUpdates(doc_id, cb) { + rclient.del(Keys.pendingUpdates({ doc_id }), cb) +} + +describe('MatrixTests', function () { + let privateProjectId, privateDocId, readWriteProjectId, readWriteDocId + + let privateClient + before(function setupPrivateProject(done) { + FixturesManager.setUpEditorSession( + { privilegeLevel: 'owner' }, + (err, { project_id, doc_id }) => { + if (err) return done(err) + privateProjectId = project_id + privateDocId = doc_id + privateClient = RealTimeClient.connect() + privateClient.on('connectionAccepted', () => { + privateClient.emit( + 'joinProject', + { project_id: privateProjectId }, + (err) => { + if (err) return done(err) + privateClient.emit('joinDoc', privateDocId, done) + } + ) + }) + } + ) + }) + + before(function setupReadWriteProject(done) { + FixturesManager.setUpEditorSession( + { + publicAccess: 'readAndWrite' + }, + (err, { project_id, doc_id }) => { + readWriteProjectId = project_id + readWriteDocId = doc_id + done(err) + } + ) + }) + + const USER_SETUP = { + anonymous: { + setup(cb) { + RealTimeClient.setSession({}, (err) => { + if (err) return cb(err) + cb(null, { + client: RealTimeClient.connect() + }) + }) + } + }, + + registered: { + setup(cb) { + const user_id = FixturesManager.getRandomId() + RealTimeClient.setSession( + { + user: { + _id: user_id, + first_name: 'Joe', + last_name: 'Bloggs' + } + }, + (err) => { + if (err) return cb(err) + cb(null, { + user_id, + client: RealTimeClient.connect() + }) + } + ) + } + }, + + registeredWithOwnedProject: { + setup(cb) { + FixturesManager.setUpEditorSession( + { privilegeLevel: 'owner' }, + (err, { project_id, user_id, doc_id }) => { + if (err) return cb(err) + cb(null, { + user_id, + project_id, + doc_id, + client: RealTimeClient.connect() + }) + } + ) + }, + hasOwnProject: true + } + } + + Object.entries(USER_SETUP).forEach((level0) => { + const [userDescription, userItem] = level0 + let options, client + + const SESSION_SETUP = { + noop: { + getActions(cb) { + cb(null, []) + }, + needsOwnProject: false + }, + + joinReadWriteProject: { + getActions(cb) { + cb(null, [ + { rpc: 'joinProject', args: [{ project_id: readWriteProjectId }] } + ]) + }, + needsOwnProject: false + }, + + joinReadWriteProjectAndDoc: { + getActions(cb) { + cb(null, [ + { rpc: 'joinProject', args: [{ project_id: readWriteProjectId }] }, + { rpc: 'joinDoc', args: [readWriteDocId] } + ]) + }, + needsOwnProject: false + }, + + joinOwnProject: { + getActions(cb) { + cb(null, [ + { rpc: 'joinProject', args: [{ project_id: options.project_id }] } + ]) + }, + needsOwnProject: true + }, + + joinOwnProjectAndDoc: { + getActions(cb) { + cb(null, [ + { rpc: 'joinProject', args: [{ project_id: options.project_id }] }, + { rpc: 'joinDoc', args: [options.doc_id] } + ]) + }, + needsOwnProject: true + } + } + + function performActions(getActions, done) { + getActions((err, actions) => { + if (err) return done(err) + + async.eachSeries( + actions, + (action, cb) => { + if (action.rpc) { + client.emit(action.rpc, ...action.args, (...returnedArgs) => { + const error = returnedArgs.shift() + if (action.fails) { + expect(error).to.exist + expect(returnedArgs).to.have.length(0) + return cb() + } + cb(error) + }) + } else { + cb(new Error('unexpected action')) + } + }, + done + ) + }) + } + + describe(userDescription, function () { + beforeEach(function userSetup(done) { + userItem.setup((err, _options) => { + if (err) return done(err) + + options = _options + client = options.client + client.on('connectionAccepted', done) + }) + }) + + Object.entries(SESSION_SETUP).forEach((level1) => { + const [sessionSetupDescription, sessionSetupItem] = level1 + const INVALID_REQUESTS = { + noop: { + getActions(cb) { + cb(null, []) + } + }, + + joinProjectWithDocId: { + getActions(cb) { + cb(null, [ + { + rpc: 'joinProject', + args: [{ project_id: privateDocId }], + fails: 1 + } + ]) + } + }, + + joinDocWithDocId: { + getActions(cb) { + cb(null, [{ rpc: 'joinDoc', args: [privateDocId], fails: 1 }]) + } + }, + + joinProjectWithProjectId: { + getActions(cb) { + cb(null, [ + { + rpc: 'joinProject', + args: [{ project_id: privateProjectId }], + fails: 1 + } + ]) + } + }, + + joinDocWithProjectId: { + getActions(cb) { + cb(null, [{ rpc: 'joinDoc', args: [privateProjectId], fails: 1 }]) + } + }, + + joinProjectWithProjectIdThenJoinDocWithDocId: { + getActions(cb) { + cb(null, [ + { + rpc: 'joinProject', + args: [{ project_id: privateProjectId }], + fails: 1 + }, + { rpc: 'joinDoc', args: [privateDocId], fails: 1 } + ]) + } + } + } + + // skip some areas of the matrix + // - some Users do not have an own project + const skip = sessionSetupItem.needsOwnProject && !userItem.hasOwnProject + + describe(sessionSetupDescription, function () { + beforeEach(function performSessionActions(done) { + if (skip) return this.skip() + performActions(sessionSetupItem.getActions, done) + }) + + Object.entries(INVALID_REQUESTS).forEach((level2) => { + const [InvalidRequestDescription, InvalidRequestItem] = level2 + describe(InvalidRequestDescription, function () { + beforeEach(function performInvalidRequests(done) { + performActions(InvalidRequestItem.getActions, done) + }) + + describe('rooms', function () { + it('should not add the user into the privateProject room', function (done) { + RealTimeClient.getConnectedClient( + client.socket.sessionid, + (error, client) => { + if (error) return done(error) + expect(client.rooms).to.not.include(privateProjectId) + done() + } + ) + }) + + it('should not add the user into the privateDoc room', function (done) { + RealTimeClient.getConnectedClient( + client.socket.sessionid, + (error, client) => { + if (error) return done(error) + expect(client.rooms).to.not.include(privateDocId) + done() + } + ) + }) + }) + + describe('receive updates', function () { + const receivedMessages = [] + beforeEach(function publishAnUpdateInRedis(done) { + const update = { + doc_id: privateDocId, + op: { + meta: { source: privateClient.publicId }, + v: 42, + doc: privateDocId, + op: [{ i: 'foo', p: 50 }] + } + } + client.on('otUpdateApplied', (update) => { + receivedMessages.push(update) + }) + privateClient.once('otUpdateApplied', () => { + setTimeout(done, 10) + }) + rclient.publish('applied-ops', JSON.stringify(update)) + }) + + it('should send nothing to client', function () { + expect(receivedMessages).to.have.length(0) + }) + }) + + describe('receive messages from web', function () { + const receivedMessages = [] + beforeEach(function publishAMessageInRedis(done) { + const event = { + room_id: privateProjectId, + message: 'removeEntity', + payload: ['foo', 'convertDocToFile'], + _id: 'web:123' + } + client.on('removeEntity', (...args) => { + receivedMessages.push(args) + }) + privateClient.once('removeEntity', () => { + setTimeout(done, 10) + }) + rclient.publish('editor-events', JSON.stringify(event)) + }) + + it('should send nothing to client', function () { + expect(receivedMessages).to.have.length(0) + }) + }) + + describe('send updates', function () { + let receivedArgs, submittedUpdates, update + + beforeEach(function cleanup(done) { + cleanupPreviousUpdates(privateDocId, done) + }) + + beforeEach(function setupUpdateFields() { + update = { + doc_id: privateDocId, + op: { + v: 43, + lastV: 42, + doc: privateDocId, + op: [{ i: 'foo', p: 50 }] + } + } + }) + + beforeEach(function sendAsUser(done) { + const userUpdate = Object.assign({}, update, { + hash: 'user' + }) + + client.emit( + 'applyOtUpdate', + privateDocId, + userUpdate, + (...args) => { + receivedArgs = args + done() + } + ) + }) + + beforeEach(function sendAsPrivateUserForReferenceOp(done) { + const privateUpdate = Object.assign({}, update, { + hash: 'private' + }) + + privateClient.emit( + 'applyOtUpdate', + privateDocId, + privateUpdate, + done + ) + }) + + beforeEach(function fetchPendingOps(done) { + getPendingUpdates(privateDocId, (err, updates) => { + submittedUpdates = updates + done(err) + }) + }) + + it('should error out trying to send', function () { + expect(receivedArgs).to.have.length(1) + expect(receivedArgs[0]).to.have.property('message') + // we are using an old version of chai: 1.9.2 + // TypeError: expect(...).to.be.oneOf is not a function + expect( + [ + 'no project_id found on client', + 'not authorized' + ].includes(receivedArgs[0].message) + ).to.equal(true) + }) + + it('should submit the private users message only', function () { + expect(submittedUpdates).to.have.length(1) + const update = JSON.parse(submittedUpdates[0]) + expect(update.hash).to.equal('private') + }) + }) + }) + }) + }) + }) + }) + }) +})