overleaf/services/real-time/test/acceptance/js/MatrixTests.js
Jakob Ackermann 5520f9eed9 Merge pull request #16002 from overleaf/jpa-real-time-auto-join-tests
[real-time] migrate acceptance tests to auto-join

GitOrigin-RevId: fc14a4e803bfab4d07f2773c6ecbdb5459d12684
2023-11-30 09:03:55 +00:00

494 lines
15 KiB
JavaScript

/*
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) {
// <setup session here>
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 }
*/
const { expect } = require('chai')
const async = require('async')
const RealTimeClient = require('./helpers/RealTimeClient')
const FixturesManager = require('./helpers/FixturesManager')
const MockWebServer = require('./helpers/MockWebServer')
const settings = require('@overleaf/settings')
const Keys = settings.redis.documentupdater.key_schema
const redis = require('@overleaf/redis-wrapper')
const rclient = redis.createClient(settings.redis.pubsub)
function getPendingUpdates(docId, cb) {
rclient.lrange(Keys.pendingUpdates({ doc_id: docId }), 0, 10, cb)
}
function cleanupPreviousUpdates(docId, cb) {
rclient.del(Keys.pendingUpdates({ doc_id: docId }), cb)
}
describe('MatrixTests', function () {
let privateProjectId,
privateDocId,
readWriteProjectId,
readWriteDocId,
readWriteAnonymousAccessToken
let privateClient
before(function setupPrivateProject(done) {
FixturesManager.setUpEditorSession(
{ privilegeLevel: 'owner', publicAccessLevel: 'readAndWrite' },
(err, { project_id: projectId, doc_id: docId }) => {
if (err) return done(err)
privateProjectId = projectId
privateDocId = docId
privateClient = RealTimeClient.connect(projectId, err => {
if (err) return done(err)
privateClient.emit('joinDoc', privateDocId, done)
})
}
)
})
before(function setupReadWriteProject(done) {
FixturesManager.setUpEditorSession(
{
publicAccess: 'readAndWrite',
},
(err, { project_id: projectId, doc_id: docId, anonymousAccessToken }) => {
readWriteProjectId = projectId
readWriteDocId = docId
readWriteAnonymousAccessToken = anonymousAccessToken
done(err)
}
)
})
const USER_SETUP = {
anonymous: {
setup(cb) {
RealTimeClient.setAnonSession(
readWriteProjectId,
readWriteAnonymousAccessToken,
err => {
if (err) return cb(err)
cb(null, {})
}
)
},
},
registered: {
setup(cb) {
const userId = FixturesManager.getRandomId()
const user = { _id: userId, first_name: 'Joe', last_name: 'Bloggs' }
RealTimeClient.setSession({ user }, err => {
if (err) return cb(err)
MockWebServer.inviteUserToProject(
readWriteProjectId,
user,
'readAndWrite'
)
cb(null, {
user_id: userId,
})
})
},
},
registeredWithOwnedProject: {
setup(cb) {
FixturesManager.setUpEditorSession(
{ privilegeLevel: 'owner' },
(err, { project_id: projectId, user_id: userId, doc_id: docId }) => {
if (err) return cb(err)
MockWebServer.inviteUserToProject(
readWriteProjectId,
{ _id: userId },
'readAndWrite'
)
cb(null, {
user_id: userId,
project_id: projectId,
doc_id: docId,
})
}
)
},
hasOwnProject: true,
},
}
Object.entries(USER_SETUP).forEach(level0 => {
const [userDescription, userItem] = level0
let options, client
const SESSION_SETUP = {
joinReadWriteProject: {
getActions(cb) {
cb(null, [{ connect: readWriteProjectId }])
},
needsOwnProject: false,
},
joinReadWriteProjectAndDoc: {
getActions(cb) {
cb(null, [
{ connect: readWriteProjectId },
{ rpc: 'joinDoc', args: [readWriteDocId] },
])
},
needsOwnProject: false,
},
joinOwnProject: {
getActions(cb) {
cb(null, [{ connect: options.project_id }])
},
needsOwnProject: true,
},
joinOwnProjectAndDoc: {
getActions(cb) {
cb(null, [
{ connect: 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, next) => {
const cb = (...returnedArgs) => {
const error = returnedArgs.shift()
if (action.fails) {
expect(error).to.exist
expect(returnedArgs).to.have.length(0)
return next()
}
next(error)
}
if (action.connect) {
client = RealTimeClient.connect(action.connect, cb)
} else if (action.rpc) {
if (client?.socket?.connected) {
client.emit(action.rpc, ...action.args, cb)
} else {
cb(new Error('not connected!'))
}
} else {
next(new Error('unexpected action'))
}
},
done
)
})
}
describe(userDescription, function () {
beforeEach(function userSetup(done) {
userItem.setup((err, _options) => {
if (err) return done(err)
options = _options
done()
})
})
Object.entries(SESSION_SETUP).forEach(level1 => {
const [sessionSetupDescription, sessionSetupItem] = level1
const INVALID_REQUESTS = {
noop: {
getActions(cb) {
cb(null, [])
},
},
joinProjectWithBadAccessToken: {
getActions(cb) {
RealTimeClient.setAnonSession(
privateProjectId,
'invalid-access-token',
err => {
if (err) return cb(err)
cb(null, [
{
connect: privateProjectId,
fails: 1,
},
])
}
)
},
},
joinProjectWithDocId: {
getActions(cb) {
cb(null, [
{
connect: privateDocId,
fails: 1,
},
])
},
},
joinDocWithDocId: {
getActions(cb) {
cb(null, [{ rpc: 'joinDoc', args: [privateDocId], fails: 1 }])
},
},
joinProjectWithProjectId: {
getActions(cb) {
cb(null, [
{
connect: privateProjectId,
fails: 1,
},
])
},
},
joinDocWithProjectId: {
getActions(cb) {
cb(null, [{ rpc: 'joinDoc', args: [privateProjectId], fails: 1 }])
},
},
joinProjectWithProjectIdThenJoinDocWithDocId: {
getActions(cb) {
cb(null, [
{
connect: 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?.message === 'not found') return done() // disconnected
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?.message === 'not found') return done() // disconnected
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) {
if (!client?.socket?.connected) {
// disconnected clients cannot emit messages
return this.skip()
}
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')
})
})
})
})
})
})
})
})
})