first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,428 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const { expect } = require('chai')
const sinon = require('sinon')
const SandboxedModule = require('sandboxed-module')
const path = require('node:path')
const modulePath = '../../../app/js/AuthorizationManager'
describe('AuthorizationManager', function () {
beforeEach(function () {
this.client = { ol_context: {} }
return (this.AuthorizationManager = SandboxedModule.require(modulePath, {
requires: {},
}))
})
describe('assertClientCanViewProject', function () {
it('should allow the readOnly privilegeLevel', function (done) {
this.client.ol_context.privilege_level = 'readOnly'
return this.AuthorizationManager.assertClientCanViewProject(
this.client,
error => {
expect(error).to.be.null
return done()
}
)
})
it('should allow the readAndWrite privilegeLevel', function (done) {
this.client.ol_context.privilege_level = 'readAndWrite'
return this.AuthorizationManager.assertClientCanViewProject(
this.client,
error => {
expect(error).to.be.null
return done()
}
)
})
it('should allow the review privilegeLevel', function (done) {
this.client.ol_context.privilege_level = 'review'
return this.AuthorizationManager.assertClientCanViewProject(
this.client,
error => {
expect(error).to.be.null
return done()
}
)
})
it('should allow the owner privilegeLevel', function (done) {
this.client.ol_context.privilege_level = 'owner'
return this.AuthorizationManager.assertClientCanViewProject(
this.client,
error => {
expect(error).to.be.null
return done()
}
)
})
return it('should return an error with any other privilegeLevel', function (done) {
this.client.ol_context.privilege_level = 'unknown'
return this.AuthorizationManager.assertClientCanViewProject(
this.client,
error => {
error.message.should.equal('not authorized')
return done()
}
)
})
})
describe('assertClientCanEditProject', function () {
it('should not allow the readOnly privilegeLevel', function (done) {
this.client.ol_context.privilege_level = 'readOnly'
return this.AuthorizationManager.assertClientCanEditProject(
this.client,
error => {
error.message.should.equal('not authorized')
return done()
}
)
})
it('should allow the readAndWrite privilegeLevel', function (done) {
this.client.ol_context.privilege_level = 'readAndWrite'
return this.AuthorizationManager.assertClientCanEditProject(
this.client,
error => {
expect(error).to.be.null
return done()
}
)
})
it('should allow the owner privilegeLevel', function (done) {
this.client.ol_context.privilege_level = 'owner'
return this.AuthorizationManager.assertClientCanEditProject(
this.client,
error => {
expect(error).to.be.null
return done()
}
)
})
return it('should return an error with any other privilegeLevel', function (done) {
this.client.ol_context.privilege_level = 'unknown'
return this.AuthorizationManager.assertClientCanEditProject(
this.client,
error => {
error.message.should.equal('not authorized')
return done()
}
)
})
})
// check doc access for project
describe('assertClientCanViewProjectAndDoc', function () {
beforeEach(function () {
this.doc_id = '12345'
this.callback = sinon.stub()
return (this.client.ol_context = {})
})
describe('when not authorised at the project level', function () {
beforeEach(function () {
return (this.client.ol_context.privilege_level = 'unknown')
})
it('should not allow access', function () {
return this.AuthorizationManager.assertClientCanViewProjectAndDoc(
this.client,
this.doc_id,
err => err.message.should.equal('not authorized')
)
})
return describe('even when authorised at the doc level', function () {
beforeEach(function (done) {
return this.AuthorizationManager.addAccessToDoc(
this.client,
this.doc_id,
done
)
})
return it('should not allow access', function () {
return this.AuthorizationManager.assertClientCanViewProjectAndDoc(
this.client,
this.doc_id,
err => err.message.should.equal('not authorized')
)
})
})
})
return describe('when authorised at the project level', function () {
beforeEach(function () {
return (this.client.ol_context.privilege_level = 'readOnly')
})
describe('and not authorised at the document level', function () {
return it('should not allow access', function () {
return this.AuthorizationManager.assertClientCanViewProjectAndDoc(
this.client,
this.doc_id,
err => err.message.should.equal('not authorized')
)
})
})
describe('and authorised at the document level', function () {
beforeEach(function (done) {
return this.AuthorizationManager.addAccessToDoc(
this.client,
this.doc_id,
done
)
})
return it('should allow access', function () {
this.AuthorizationManager.assertClientCanViewProjectAndDoc(
this.client,
this.doc_id,
this.callback
)
return this.callback.calledWith(null).should.equal(true)
})
})
return describe('when document authorisation is added and then removed', function () {
beforeEach(function (done) {
return this.AuthorizationManager.addAccessToDoc(
this.client,
this.doc_id,
() => {
return this.AuthorizationManager.removeAccessToDoc(
this.client,
this.doc_id,
done
)
}
)
})
return it('should deny access', function () {
return this.AuthorizationManager.assertClientCanViewProjectAndDoc(
this.client,
this.doc_id,
err => err.message.should.equal('not authorized')
)
})
})
})
})
describe('assertClientCanEditProjectAndDoc', function () {
beforeEach(function () {
this.doc_id = '12345'
this.callback = sinon.stub()
return (this.client.ol_context = {})
})
describe('when not authorised at the project level', function () {
beforeEach(function () {
return (this.client.ol_context.privilege_level = 'readOnly')
})
it('should not allow access', function () {
return this.AuthorizationManager.assertClientCanEditProjectAndDoc(
this.client,
this.doc_id,
err => err.message.should.equal('not authorized')
)
})
return describe('even when authorised at the doc level', function () {
beforeEach(function (done) {
return this.AuthorizationManager.addAccessToDoc(
this.client,
this.doc_id,
done
)
})
return it('should not allow access', function () {
return this.AuthorizationManager.assertClientCanEditProjectAndDoc(
this.client,
this.doc_id,
err => err.message.should.equal('not authorized')
)
})
})
})
return describe('when authorised at the project level', function () {
beforeEach(function () {
return (this.client.ol_context.privilege_level = 'readAndWrite')
})
describe('and not authorised at the document level', function () {
return it('should not allow access', function () {
return this.AuthorizationManager.assertClientCanEditProjectAndDoc(
this.client,
this.doc_id,
err => err.message.should.equal('not authorized')
)
})
})
describe('and authorised at the document level', function () {
beforeEach(function (done) {
return this.AuthorizationManager.addAccessToDoc(
this.client,
this.doc_id,
done
)
})
return it('should allow access', function () {
this.AuthorizationManager.assertClientCanEditProjectAndDoc(
this.client,
this.doc_id,
this.callback
)
return this.callback.calledWith(null).should.equal(true)
})
})
return describe('when document authorisation is added and then removed', function () {
beforeEach(function (done) {
return this.AuthorizationManager.addAccessToDoc(
this.client,
this.doc_id,
() => {
return this.AuthorizationManager.removeAccessToDoc(
this.client,
this.doc_id,
done
)
}
)
})
return it('should deny access', function () {
return this.AuthorizationManager.assertClientCanEditProjectAndDoc(
this.client,
this.doc_id,
err => err.message.should.equal('not authorized')
)
})
})
})
})
return describe('assertClientCanReviewProjectAndDoc', function () {
beforeEach(function () {
this.doc_id = '12345'
this.callback = sinon.stub()
return (this.client.ol_context = {})
})
describe('when not authorised at the project level', function () {
beforeEach(function () {
return (this.client.ol_context.privilege_level = 'readOnly')
})
it('should not allow access', function () {
return this.AuthorizationManager.assertClientCanReviewProjectAndDoc(
this.client,
this.doc_id,
err => err.message.should.equal('not authorized')
)
})
return describe('even when authorised at the doc level', function () {
beforeEach(function (done) {
return this.AuthorizationManager.addAccessToDoc(
this.client,
this.doc_id,
done
)
})
return it('should not allow access', function () {
return this.AuthorizationManager.assertClientCanReviewProjectAndDoc(
this.client,
this.doc_id,
err => err.message.should.equal('not authorized')
)
})
})
})
return describe('when authorised at the project level', function () {
beforeEach(function () {
return (this.client.ol_context.privilege_level = 'review')
})
describe('and not authorised at the document level', function () {
return it('should not allow access', function () {
return this.AuthorizationManager.assertClientCanReviewProjectAndDoc(
this.client,
this.doc_id,
err => err.message.should.equal('not authorized')
)
})
})
describe('and authorised at the document level', function () {
beforeEach(function (done) {
return this.AuthorizationManager.addAccessToDoc(
this.client,
this.doc_id,
done
)
})
return it('should allow access', function () {
this.AuthorizationManager.assertClientCanReviewProjectAndDoc(
this.client,
this.doc_id,
this.callback
)
return this.callback.calledWith(null).should.equal(true)
})
})
return describe('when document authorisation is added and then removed', function () {
beforeEach(function (done) {
return this.AuthorizationManager.addAccessToDoc(
this.client,
this.doc_id,
() => {
return this.AuthorizationManager.removeAccessToDoc(
this.client,
this.doc_id,
done
)
}
)
})
return it('should deny access', function () {
return this.AuthorizationManager.assertClientCanReviewProjectAndDoc(
this.client,
this.doc_id,
err => err.message.should.equal('not authorized')
)
})
})
})
})
})

View File

@@ -0,0 +1,432 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const { expect } = require('chai')
const sinon = require('sinon')
const modulePath = '../../../app/js/ChannelManager.js'
const SandboxedModule = require('sandboxed-module')
describe('ChannelManager', function () {
beforeEach(function () {
this.rclient = {}
this.other_rclient = {}
return (this.ChannelManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.settings = {}),
'@overleaf/metrics': (this.metrics = {
inc: sinon.stub(),
summary: sinon.stub(),
}),
},
}))
})
describe('subscribe', function () {
describe('when there is no existing subscription for this redis client', function () {
beforeEach(function (done) {
this.rclient.subscribe = sinon.stub().resolves()
this.ChannelManager.subscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
return setTimeout(done)
})
return it('should subscribe to the redis channel', function () {
return this.rclient.subscribe
.calledWithExactly('applied-ops:1234567890abcdef')
.should.equal(true)
})
})
describe('when there is an existing subscription for this redis client', function () {
beforeEach(function (done) {
this.rclient.subscribe = sinon.stub().resolves()
this.ChannelManager.subscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
this.ChannelManager.subscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
return setTimeout(done)
})
return it('should subscribe to the redis channel again', function () {
return this.rclient.subscribe.callCount.should.equal(2)
})
})
describe('when subscribe errors', function () {
beforeEach(function (done) {
this.rclient.subscribe = sinon
.stub()
.onFirstCall()
.rejects(new Error('some redis error'))
.onSecondCall()
.resolves()
const p = this.ChannelManager.subscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
p.then(() => done(new Error('should not subscribe but fail'))).catch(
err => {
err.message.should.equal('failed to subscribe to channel')
err.cause.message.should.equal('some redis error')
this.ChannelManager.getClientMapEntry(this.rclient)
.has('applied-ops:1234567890abcdef')
.should.equal(false)
this.ChannelManager.subscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
// subscribe is wrapped in Promise, delay other assertions
return setTimeout(done)
}
)
return null
})
it('should have recorded the error', function () {
return expect(
this.metrics.inc.calledWithExactly('subscribe.failed.applied-ops')
).to.equal(true)
})
it('should subscribe again', function () {
return this.rclient.subscribe.callCount.should.equal(2)
})
return it('should cleanup', function () {
return this.ChannelManager.getClientMapEntry(this.rclient)
.has('applied-ops:1234567890abcdef')
.should.equal(false)
})
})
describe('when subscribe errors and the clientChannelMap entry was replaced', function () {
beforeEach(function (done) {
this.rclient.subscribe = sinon
.stub()
.onFirstCall()
.rejects(new Error('some redis error'))
.onSecondCall()
.resolves()
this.first = this.ChannelManager.subscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
// ignore error
this.first.catch(() => {})
expect(
this.ChannelManager.getClientMapEntry(this.rclient).get(
'applied-ops:1234567890abcdef'
)
).to.equal(this.first)
this.rclient.unsubscribe = sinon.stub().resolves()
this.ChannelManager.unsubscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
this.second = this.ChannelManager.subscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
// should get replaced immediately
expect(
this.ChannelManager.getClientMapEntry(this.rclient).get(
'applied-ops:1234567890abcdef'
)
).to.equal(this.second)
// let the first subscribe error -> unsubscribe -> subscribe
return setTimeout(done)
})
return it('should cleanup the second subscribePromise', function () {
return expect(
this.ChannelManager.getClientMapEntry(this.rclient).has(
'applied-ops:1234567890abcdef'
)
).to.equal(false)
})
})
return describe('when there is an existing subscription for another redis client but not this one', function () {
beforeEach(function (done) {
this.other_rclient.subscribe = sinon.stub().resolves()
this.ChannelManager.subscribe(
this.other_rclient,
'applied-ops',
'1234567890abcdef'
)
this.rclient.subscribe = sinon.stub().resolves() // discard the original stub
this.ChannelManager.subscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
return setTimeout(done)
})
return it('should subscribe to the redis channel on this redis client', function () {
return this.rclient.subscribe
.calledWithExactly('applied-ops:1234567890abcdef')
.should.equal(true)
})
})
})
describe('unsubscribe', function () {
describe('when there is no existing subscription for this redis client', function () {
beforeEach(function (done) {
this.rclient.unsubscribe = sinon.stub().resolves()
this.ChannelManager.unsubscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
return setTimeout(done)
})
return it('should unsubscribe from the redis channel', function () {
return this.rclient.unsubscribe.called.should.equal(true)
})
})
describe('when there is an existing subscription for this another redis client but not this one', function () {
beforeEach(function (done) {
this.other_rclient.subscribe = sinon.stub().resolves()
this.rclient.unsubscribe = sinon.stub().resolves()
this.ChannelManager.subscribe(
this.other_rclient,
'applied-ops',
'1234567890abcdef'
)
this.ChannelManager.unsubscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
return setTimeout(done)
})
return it('should still unsubscribe from the redis channel on this client', function () {
return this.rclient.unsubscribe.called.should.equal(true)
})
})
describe('when unsubscribe errors and completes', function () {
beforeEach(function (done) {
this.rclient.subscribe = sinon.stub().resolves()
this.ChannelManager.subscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
this.rclient.unsubscribe = sinon
.stub()
.rejects(new Error('some redis error'))
this.ChannelManager.unsubscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
setTimeout(done)
return null
})
it('should have cleaned up', function () {
return this.ChannelManager.getClientMapEntry(this.rclient)
.has('applied-ops:1234567890abcdef')
.should.equal(false)
})
return it('should not error out when subscribing again', function (done) {
const p = this.ChannelManager.subscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
p.then(() => done()).catch(done)
return null
})
})
describe('when unsubscribe errors and another client subscribes at the same time', function () {
beforeEach(function (done) {
this.rclient.subscribe = sinon.stub().resolves()
this.ChannelManager.subscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
let rejectSubscribe
this.rclient.unsubscribe = () =>
new Promise((resolve, reject) => (rejectSubscribe = reject))
this.ChannelManager.unsubscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
setTimeout(() => {
// delay, actualUnsubscribe should not see the new subscribe request
this.ChannelManager.subscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
.then(() => setTimeout(done))
.catch(done)
return setTimeout(() =>
// delay, rejectSubscribe is not defined immediately
rejectSubscribe(new Error('redis error'))
)
})
return null
})
it('should have recorded the error', function () {
return expect(
this.metrics.inc.calledWithExactly('unsubscribe.failed.applied-ops')
).to.equal(true)
})
it('should have subscribed', function () {
return this.rclient.subscribe.called.should.equal(true)
})
return it('should have discarded the finished Promise', function () {
return this.ChannelManager.getClientMapEntry(this.rclient)
.has('applied-ops:1234567890abcdef')
.should.equal(false)
})
})
return describe('when there is an existing subscription for this redis client', function () {
beforeEach(function (done) {
this.rclient.subscribe = sinon.stub().resolves()
this.rclient.unsubscribe = sinon.stub().resolves()
this.ChannelManager.subscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
this.ChannelManager.unsubscribe(
this.rclient,
'applied-ops',
'1234567890abcdef'
)
return setTimeout(done)
})
return it('should unsubscribe from the redis channel', function () {
return this.rclient.unsubscribe
.calledWithExactly('applied-ops:1234567890abcdef')
.should.equal(true)
})
})
})
return describe('publish', function () {
describe("when the channel is 'all'", function () {
beforeEach(function () {
this.rclient.publish = sinon.stub()
return this.ChannelManager.publish(
this.rclient,
'applied-ops',
'all',
'random-message'
)
})
return it('should publish on the base channel', function () {
return this.rclient.publish
.calledWithExactly('applied-ops', 'random-message')
.should.equal(true)
})
})
describe('when the channel has an specific id', function () {
describe('when the individual channel setting is false', function () {
beforeEach(function () {
this.rclient.publish = sinon.stub()
this.settings.publishOnIndividualChannels = false
return this.ChannelManager.publish(
this.rclient,
'applied-ops',
'1234567890abcdef',
'random-message'
)
})
return it('should publish on the per-id channel', function () {
this.rclient.publish
.calledWithExactly('applied-ops', 'random-message')
.should.equal(true)
return this.rclient.publish.calledOnce.should.equal(true)
})
})
return describe('when the individual channel setting is true', function () {
beforeEach(function () {
this.rclient.publish = sinon.stub()
this.settings.publishOnIndividualChannels = true
return this.ChannelManager.publish(
this.rclient,
'applied-ops',
'1234567890abcdef',
'random-message'
)
})
return it('should publish on the per-id channel', function () {
this.rclient.publish
.calledWithExactly('applied-ops:1234567890abcdef', 'random-message')
.should.equal(true)
return this.rclient.publish.calledOnce.should.equal(true)
})
})
})
return describe('metrics', function () {
beforeEach(function () {
this.rclient.publish = sinon.stub()
return this.ChannelManager.publish(
this.rclient,
'applied-ops',
'all',
'random-message'
)
})
return it('should track the payload size', function () {
return this.metrics.summary
.calledWithExactly(
'redis.publish.applied-ops',
'random-message'.length
)
.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,648 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const SandboxedModule = require('sandboxed-module')
const assert = require('node:assert')
const path = require('node:path')
const sinon = require('sinon')
const modulePath = path.join(__dirname, '../../../app/js/ConnectedUsersManager')
const { expect } = require('chai')
const tk = require('timekeeper')
describe('ConnectedUsersManager', function () {
beforeEach(function () {
tk.freeze(new Date())
this.settings = {
redis: {
realtime: {
key_schema: {
clientsInProject({ project_id: projectId }) {
return `clients_in_project:${projectId}`
},
connectedUser({ project_id: projectId, client_id: clientId }) {
return `connected_user:${projectId}:${clientId}`
},
projectNotEmptySince({ projectId }) {
return `projectNotEmptySince:{${projectId}}`
},
},
},
},
}
this.rClient = {
auth() {},
getdel: sinon.stub(),
scard: sinon.stub(),
set: sinon.stub(),
setex: sinon.stub(),
sadd: sinon.stub(),
get: sinon.stub(),
srem: sinon.stub(),
del: sinon.stub(),
smembers: sinon.stub(),
expire: sinon.stub(),
hset: sinon.stub(),
hgetall: sinon.stub(),
exec: sinon.stub(),
multi: () => {
return this.rClient
},
}
this.Metrics = {
inc: sinon.stub(),
histogram: sinon.stub(),
}
this.ConnectedUsersManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': this.settings,
'@overleaf/metrics': this.Metrics,
'@overleaf/redis-wrapper': {
createClient: () => {
return this.rClient
},
},
},
})
this.client_id = '32132132'
this.project_id = 'dskjh2u21321'
this.user = {
_id: 'user-id-123',
first_name: 'Joe',
last_name: 'Bloggs',
email: 'joe@example.com',
}
return (this.cursorData = {
row: 12,
column: 9,
doc_id: '53c3b8c85fee64000023dc6e',
})
})
afterEach(function () {
return tk.reset()
})
describe('updateUserPosition', function () {
beforeEach(function () {
this.rClient.exec.yields(null, [1, 1])
})
it('should set a key with the date and give it a ttl', function (done) {
return this.ConnectedUsersManager.updateUserPosition(
this.project_id,
this.client_id,
this.user,
null,
err => {
if (err) return done(err)
this.rClient.hset
.calledWith(
`connected_user:${this.project_id}:${this.client_id}`,
'last_updated_at',
Date.now()
)
.should.equal(true)
return done()
}
)
})
it('should set a key with the user_id', function (done) {
return this.ConnectedUsersManager.updateUserPosition(
this.project_id,
this.client_id,
this.user,
null,
err => {
if (err) return done(err)
this.rClient.hset
.calledWith(
`connected_user:${this.project_id}:${this.client_id}`,
'user_id',
this.user._id
)
.should.equal(true)
return done()
}
)
})
it('should set a key with the first_name', function (done) {
return this.ConnectedUsersManager.updateUserPosition(
this.project_id,
this.client_id,
this.user,
null,
err => {
if (err) return done(err)
this.rClient.hset
.calledWith(
`connected_user:${this.project_id}:${this.client_id}`,
'first_name',
this.user.first_name
)
.should.equal(true)
return done()
}
)
})
it('should set a key with the last_name', function (done) {
return this.ConnectedUsersManager.updateUserPosition(
this.project_id,
this.client_id,
this.user,
null,
err => {
if (err) return done(err)
this.rClient.hset
.calledWith(
`connected_user:${this.project_id}:${this.client_id}`,
'last_name',
this.user.last_name
)
.should.equal(true)
return done()
}
)
})
it('should set a key with the email', function (done) {
return this.ConnectedUsersManager.updateUserPosition(
this.project_id,
this.client_id,
this.user,
null,
err => {
if (err) return done(err)
this.rClient.hset
.calledWith(
`connected_user:${this.project_id}:${this.client_id}`,
'email',
this.user.email
)
.should.equal(true)
return done()
}
)
})
it('should push the client_id on to the project list', function (done) {
return this.ConnectedUsersManager.updateUserPosition(
this.project_id,
this.client_id,
this.user,
null,
err => {
if (err) return done(err)
this.rClient.sadd
.calledWith(`clients_in_project:${this.project_id}`, this.client_id)
.should.equal(true)
return done()
}
)
})
it('should add a ttl to the project set so it stays clean', function (done) {
return this.ConnectedUsersManager.updateUserPosition(
this.project_id,
this.client_id,
this.user,
null,
err => {
if (err) return done(err)
this.rClient.expire
.calledWith(
`clients_in_project:${this.project_id}`,
24 * 4 * 60 * 60
)
.should.equal(true)
return done()
}
)
})
it('should add a ttl to the connected user so it stays clean', function (done) {
return this.ConnectedUsersManager.updateUserPosition(
this.project_id,
this.client_id,
this.user,
null,
err => {
if (err) return done(err)
this.rClient.expire
.calledWith(
`connected_user:${this.project_id}:${this.client_id}`,
60 * 15
)
.should.equal(true)
return done()
}
)
})
it('should set the cursor position when provided', function (done) {
return this.ConnectedUsersManager.updateUserPosition(
this.project_id,
this.client_id,
this.user,
this.cursorData,
err => {
if (err) return done(err)
this.rClient.hset
.calledWith(
`connected_user:${this.project_id}:${this.client_id}`,
'cursorData',
JSON.stringify(this.cursorData)
)
.should.equal(true)
return done()
}
)
})
describe('editing_session_mode', function () {
const cases = {
'should bump the metric when connecting to empty room': {
nConnectedClients: 1,
cursorData: null,
labels: {
method: 'connect',
status: 'single',
},
},
'should bump the metric when connecting to non-empty room': {
nConnectedClients: 2,
cursorData: null,
labels: {
method: 'connect',
status: 'multi',
},
},
'should bump the metric when updating in empty room': {
nConnectedClients: 1,
cursorData: { row: 42 },
labels: {
method: 'update',
status: 'single',
},
},
'should bump the metric when updating in non-empty room': {
nConnectedClients: 2,
cursorData: { row: 42 },
labels: {
method: 'update',
status: 'multi',
},
},
}
for (const [
name,
{ nConnectedClients, cursorData, labels },
] of Object.entries(cases)) {
it(name, function (done) {
this.rClient.exec.yields(null, [1, nConnectedClients])
this.ConnectedUsersManager.updateUserPosition(
this.project_id,
this.client_id,
this.user,
cursorData,
err => {
if (err) return done(err)
expect(this.Metrics.inc).to.have.been.calledWith(
'editing_session_mode',
1,
labels
)
done()
}
)
})
}
})
})
describe('markUserAsDisconnected', function () {
beforeEach(function () {
this.rClient.exec.yields(null, [1, 0])
})
it('should remove the user from the set', function (done) {
return this.ConnectedUsersManager.markUserAsDisconnected(
this.project_id,
this.client_id,
err => {
if (err) return done(err)
this.rClient.srem
.calledWith(`clients_in_project:${this.project_id}`, this.client_id)
.should.equal(true)
return done()
}
)
})
it('should delete the connected_user string', function (done) {
return this.ConnectedUsersManager.markUserAsDisconnected(
this.project_id,
this.client_id,
err => {
if (err) return done(err)
this.rClient.del
.calledWith(`connected_user:${this.project_id}:${this.client_id}`)
.should.equal(true)
return done()
}
)
})
it('should add a ttl to the connected user set so it stays clean', function (done) {
return this.ConnectedUsersManager.markUserAsDisconnected(
this.project_id,
this.client_id,
err => {
if (err) return done(err)
this.rClient.expire
.calledWith(
`clients_in_project:${this.project_id}`,
24 * 4 * 60 * 60
)
.should.equal(true)
return done()
}
)
})
describe('editing_session_mode', function () {
const cases = {
'should bump the metric when disconnecting from now empty room': {
nConnectedClients: 0,
labels: {
method: 'disconnect',
status: 'empty',
},
},
'should bump the metric when disconnecting from now single room': {
nConnectedClients: 1,
labels: {
method: 'disconnect',
status: 'single',
},
},
'should bump the metric when disconnecting from now multi room': {
nConnectedClients: 2,
labels: {
method: 'disconnect',
status: 'multi',
},
},
}
for (const [name, { nConnectedClients, labels }] of Object.entries(
cases
)) {
it(name, function (done) {
this.rClient.exec.yields(null, [1, nConnectedClients])
this.ConnectedUsersManager.markUserAsDisconnected(
this.project_id,
this.client_id,
err => {
if (err) return done(err)
expect(this.Metrics.inc).to.have.been.calledWith(
'editing_session_mode',
1,
labels
)
done()
}
)
})
}
})
describe('projectNotEmptySince', function () {
it('should clear the projectNotEmptySince key when empty and skip metric if not set', function (done) {
this.rClient.exec.yields(null, [1, 0])
this.rClient.getdel.yields(null, '')
this.ConnectedUsersManager.markUserAsDisconnected(
this.project_id,
this.client_id,
err => {
if (err) return done(err)
expect(this.rClient.getdel).to.have.been.calledWith(
`projectNotEmptySince:{${this.project_id}}`
)
expect(this.Metrics.histogram).to.not.have.been.called
done()
}
)
})
it('should clear the projectNotEmptySince key when empty and record metric if set', function (done) {
this.rClient.exec.onFirstCall().yields(null, [1, 0])
tk.freeze(1_234_000)
this.rClient.getdel.yields(null, '1230')
this.ConnectedUsersManager.markUserAsDisconnected(
this.project_id,
this.client_id,
err => {
if (err) return done(err)
expect(this.rClient.getdel).to.have.been.calledWith(
`projectNotEmptySince:{${this.project_id}}`
)
expect(this.Metrics.histogram).to.have.been.calledWith(
'project_not_empty_since',
4,
sinon.match.any,
{ status: 'empty' }
)
done()
}
)
})
it('should set projectNotEmptySince key when single and skip metric if not set before', function (done) {
this.rClient.exec.onFirstCall().yields(null, [1, 1])
tk.freeze(1_233_001) // should ceil up
this.rClient.exec.onSecondCall().yields(null, [''])
this.ConnectedUsersManager.markUserAsDisconnected(
this.project_id,
this.client_id,
err => {
if (err) return done(err)
expect(this.rClient.set).to.have.been.calledWith(
`projectNotEmptySince:{${this.project_id}}`,
'1234',
'NX',
'EX',
31 * 24 * 60 * 60
)
expect(this.Metrics.histogram).to.not.have.been.called
done()
}
)
})
const cases = {
'should set projectNotEmptySince key when single and record metric if set before':
{
nConnectedClients: 1,
labels: {
status: 'single',
},
},
'should set projectNotEmptySince key when multi and record metric if set before':
{
nConnectedClients: 2,
labels: {
status: 'multi',
},
},
}
for (const [name, { nConnectedClients, labels }] of Object.entries(
cases
)) {
it(name, function (done) {
this.rClient.exec.onFirstCall().yields(null, [1, nConnectedClients])
tk.freeze(1_235_000)
this.rClient.exec.onSecondCall().yields(null, ['1230'])
this.ConnectedUsersManager.markUserAsDisconnected(
this.project_id,
this.client_id,
err => {
if (err) return done(err)
expect(this.rClient.set).to.have.been.calledWith(
`projectNotEmptySince:{${this.project_id}}`,
'1235',
'NX',
'EX',
31 * 24 * 60 * 60
)
expect(this.Metrics.histogram).to.have.been.calledWith(
'project_not_empty_since',
5,
sinon.match.any,
labels
)
done()
}
)
})
}
})
})
describe('_getConnectedUser', function () {
it('should return a connected user if there is a user object', function (done) {
const cursorData = JSON.stringify({ cursorData: { row: 1 } })
this.rClient.hgetall.callsArgWith(1, null, {
connected_at: new Date(),
user_id: this.user._id,
last_updated_at: `${Date.now()}`,
cursorData,
})
return this.ConnectedUsersManager._getConnectedUser(
this.project_id,
this.client_id,
(err, result) => {
if (err) return done(err)
result.connected.should.equal(true)
result.client_id.should.equal(this.client_id)
return done()
}
)
})
it('should return a not connected user if there is no object', function (done) {
this.rClient.hgetall.callsArgWith(1, null, null)
return this.ConnectedUsersManager._getConnectedUser(
this.project_id,
this.client_id,
(err, result) => {
if (err) return done(err)
result.connected.should.equal(false)
result.client_id.should.equal(this.client_id)
return done()
}
)
})
return it('should return a not connected user if there is an empty object', function (done) {
this.rClient.hgetall.callsArgWith(1, null, {})
return this.ConnectedUsersManager._getConnectedUser(
this.project_id,
this.client_id,
(err, result) => {
if (err) return done(err)
result.connected.should.equal(false)
result.client_id.should.equal(this.client_id)
return done()
}
)
})
})
return describe('getConnectedUsers', function () {
beforeEach(function () {
this.users = ['1234', '5678', '9123', '8234']
this.rClient.smembers.callsArgWith(1, null, this.users)
this.ConnectedUsersManager._getConnectedUser = sinon.stub()
this.ConnectedUsersManager._getConnectedUser
.withArgs(this.project_id, this.users[0])
.callsArgWith(2, null, {
connected: true,
client_age: 2,
client_id: this.users[0],
})
this.ConnectedUsersManager._getConnectedUser
.withArgs(this.project_id, this.users[1])
.callsArgWith(2, null, {
connected: false,
client_age: 1,
client_id: this.users[1],
})
this.ConnectedUsersManager._getConnectedUser
.withArgs(this.project_id, this.users[2])
.callsArgWith(2, null, {
connected: true,
client_age: 3,
client_id: this.users[2],
})
return this.ConnectedUsersManager._getConnectedUser
.withArgs(this.project_id, this.users[3])
.callsArgWith(2, null, {
connected: true,
client_age: 11,
client_id: this.users[3],
})
}) // connected but old
return it('should only return the users in the list which are still in redis and recently updated', function (done) {
return this.ConnectedUsersManager.getConnectedUsers(
this.project_id,
(err, users) => {
if (err) return done(err)
users.length.should.equal(2)
users[0].should.deep.equal({
client_id: this.users[0],
client_age: 2,
connected: true,
})
users[1].should.deep.equal({
client_id: this.users[2],
client_age: 3,
connected: true,
})
return done()
}
)
})
})
})

View File

@@ -0,0 +1,259 @@
/* eslint-disable
no-return-assign,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const modulePath = require('node:path').join(
__dirname,
'../../../app/js/DocumentUpdaterController'
)
const MockClient = require('./helpers/MockClient')
describe('DocumentUpdaterController', function () {
beforeEach(function () {
this.project_id = 'project-id-123'
this.doc_id = 'doc-id-123'
this.callback = sinon.stub()
this.io = { mock: 'socket.io' }
this.rclient = []
this.RoomEvents = { on: sinon.stub() }
this.EditorUpdatesController = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.settings = {
redis: {
documentupdater: {
key_schema: {
pendingUpdates({ doc_id: docId }) {
return `PendingUpdates:${docId}`
},
},
},
pubsub: null,
},
}),
'./RedisClientManager': {
createClientList: () => {
this.redis = {
createClient: name => {
let rclientStub
this.rclient.push((rclientStub = { name }))
return rclientStub
},
}
},
},
'./SafeJsonParse': (this.SafeJsonParse = {
parse: (data, cb) => cb(null, JSON.parse(data)),
}),
'./EventLogger': (this.EventLogger = { checkEventOrder: sinon.stub() }),
'./HealthCheckManager': { check: sinon.stub() },
'@overleaf/metrics': (this.metrics = {
inc: sinon.stub(),
histogram: sinon.stub(),
}),
'./RoomManager': (this.RoomManager = {
eventSource: sinon.stub().returns(this.RoomEvents),
}),
'./ChannelManager': (this.ChannelManager = {}),
},
})
})
describe('listenForUpdatesFromDocumentUpdater', function () {
beforeEach(function () {
this.rclient.length = 0 // clear any existing clients
this.EditorUpdatesController.rclientList = [
this.redis.createClient('first'),
this.redis.createClient('second'),
]
this.rclient[0].subscribe = sinon.stub()
this.rclient[0].on = sinon.stub()
this.rclient[1].subscribe = sinon.stub()
this.rclient[1].on = sinon.stub()
this.EditorUpdatesController.listenForUpdatesFromDocumentUpdater()
})
it('should subscribe to the doc-updater stream', function () {
this.rclient[0].subscribe.calledWith('applied-ops').should.equal(true)
})
it('should register a callback to handle updates', function () {
this.rclient[0].on.calledWith('message').should.equal(true)
})
it('should subscribe to any additional doc-updater stream', function () {
this.rclient[1].subscribe.calledWith('applied-ops').should.equal(true)
this.rclient[1].on.calledWith('message').should.equal(true)
})
})
describe('_processMessageFromDocumentUpdater', function () {
describe('with bad JSON', function () {
beforeEach(function () {
this.SafeJsonParse.parse = sinon
.stub()
.callsArgWith(1, new Error('oops'))
return this.EditorUpdatesController._processMessageFromDocumentUpdater(
this.io,
'applied-ops',
'blah'
)
})
it('should log an error', function () {
return this.logger.error.called.should.equal(true)
})
})
describe('with update', function () {
beforeEach(function () {
this.message = {
doc_id: this.doc_id,
op: { t: 'foo', p: 12 },
}
this.EditorUpdatesController._applyUpdateFromDocumentUpdater =
sinon.stub()
return this.EditorUpdatesController._processMessageFromDocumentUpdater(
this.io,
'applied-ops',
JSON.stringify(this.message)
)
})
it('should apply the update', function () {
return this.EditorUpdatesController._applyUpdateFromDocumentUpdater
.calledWith(this.io, this.doc_id, this.message.op)
.should.equal(true)
})
})
describe('with error', function () {
beforeEach(function () {
this.message = {
doc_id: this.doc_id,
error: 'Something went wrong',
}
this.EditorUpdatesController._processErrorFromDocumentUpdater =
sinon.stub()
return this.EditorUpdatesController._processMessageFromDocumentUpdater(
this.io,
'applied-ops',
JSON.stringify(this.message)
)
})
return it('should process the error', function () {
return this.EditorUpdatesController._processErrorFromDocumentUpdater
.calledWith(this.io, this.doc_id, this.message.error)
.should.equal(true)
})
})
})
describe('_applyUpdateFromDocumentUpdater', function () {
beforeEach(function () {
this.sourceClient = new MockClient()
this.otherClients = [new MockClient(), new MockClient()]
this.update = {
op: [{ t: 'foo', p: 12 }],
meta: { source: this.sourceClient.publicId },
v: (this.version = 42),
doc: this.doc_id,
}
return (this.io.sockets = {
clients: sinon
.stub()
.returns([
this.sourceClient,
...Array.from(this.otherClients),
this.sourceClient,
]),
})
}) // include a duplicate client
describe('normally', function () {
beforeEach(function () {
return this.EditorUpdatesController._applyUpdateFromDocumentUpdater(
this.io,
this.doc_id,
this.update
)
})
it('should send a version bump to the source client', function () {
this.sourceClient.emit
.calledWith('otUpdateApplied', { v: this.version, doc: this.doc_id })
.should.equal(true)
return this.sourceClient.emit.calledOnce.should.equal(true)
})
it('should get the clients connected to the document', function () {
return this.io.sockets.clients
.calledWith(this.doc_id)
.should.equal(true)
})
return it('should send the full update to the other clients', function () {
return Array.from(this.otherClients).map(client =>
client.emit
.calledWith('otUpdateApplied', this.update)
.should.equal(true)
)
})
})
return describe('with a duplicate op', function () {
beforeEach(function () {
this.update.dup = true
return this.EditorUpdatesController._applyUpdateFromDocumentUpdater(
this.io,
this.doc_id,
this.update
)
})
it('should send a version bump to the source client as usual', function () {
return this.sourceClient.emit
.calledWith('otUpdateApplied', { v: this.version, doc: this.doc_id })
.should.equal(true)
})
return it("should not send anything to the other clients (they've already had the op)", function () {
return Array.from(this.otherClients).map(client =>
client.emit.calledWith('otUpdateApplied').should.equal(false)
)
})
})
})
return describe('_processErrorFromDocumentUpdater', function () {
beforeEach(function () {
this.clients = [new MockClient(), new MockClient()]
this.io.sockets = { clients: sinon.stub().returns(this.clients) }
return this.EditorUpdatesController._processErrorFromDocumentUpdater(
this.io,
this.doc_id,
'Something went wrong'
)
})
it('should log a warning', function () {
return this.logger.warn.called.should.equal(true)
})
return it('should disconnect all clients in that document', function () {
this.io.sockets.clients.calledWith(this.doc_id).should.equal(true)
return Array.from(this.clients).map(client =>
client.disconnect.called.should.equal(true)
)
})
})
})

View File

@@ -0,0 +1,423 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const SandboxedModule = require('sandboxed-module')
const path = require('node:path')
const modulePath = '../../../app/js/DocumentUpdaterManager'
const _ = require('lodash')
describe('DocumentUpdaterManager', function () {
beforeEach(function () {
let Timer
this.project_id = 'project-id-923'
this.doc_id = 'doc-id-394'
this.lines = ['one', 'two', 'three']
this.version = 42
this.settings = {
apis: { documentupdater: { url: 'http://doc-updater.example.com' } },
redis: {
documentupdater: {
key_schema: {
pendingUpdates({ doc_id: docId }) {
return `PendingUpdates:${docId}`
},
},
},
},
maxUpdateSize: 7 * 1024 * 1024,
pendingUpdateListShardCount: 10,
}
this.rclient = { auth() {} }
return (this.DocumentUpdaterManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': this.settings,
request: (this.request = {}),
'@overleaf/redis-wrapper': { createClient: () => this.rclient },
'@overleaf/metrics': (this.Metrics = {
summary: sinon.stub(),
Timer: (Timer = class Timer {
done() {}
}),
}),
},
}))
}) // avoid modifying JSON object directly
describe('getDocument', function () {
beforeEach(function () {
return (this.callback = sinon.stub())
})
describe('successfully', function () {
beforeEach(function () {
this.body = JSON.stringify({
lines: this.lines,
version: this.version,
ops: (this.ops = ['mock-op-1', 'mock-op-2']),
ranges: (this.ranges = { mock: 'ranges' }),
})
this.fromVersion = 2
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 200 }, this.body)
return this.DocumentUpdaterManager.getDocument(
this.project_id,
this.doc_id,
this.fromVersion,
this.callback
)
})
it('should get the document from the document updater', function () {
const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}?fromVersion=${this.fromVersion}`
return this.request.get.calledWith(url).should.equal(true)
})
return it('should call the callback with the lines, version, ranges and ops', function () {
return this.callback
.calledWith(null, this.lines, this.version, this.ranges, this.ops)
.should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.get = sinon
.stub()
.callsArgWith(
1,
(this.error = new Error('something went wrong')),
null,
null
)
return this.DocumentUpdaterManager.getDocument(
this.project_id,
this.doc_id,
this.fromVersion,
this.callback
)
})
return it('should return an error to the callback', function () {
return this.callback.calledWith(this.error).should.equal(true)
})
})
;[404, 422].forEach(statusCode =>
describe(`when the document updater returns a ${statusCode} status code`, function () {
beforeEach(function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode }, '')
return this.DocumentUpdaterManager.getDocument(
this.project_id,
this.doc_id,
this.fromVersion,
this.callback
)
})
return it('should return the callback with an error', function () {
this.callback.called.should.equal(true)
this.callback
.calledWith(
sinon.match({
message: 'doc updater could not load requested ops',
info: { statusCode },
})
)
.should.equal(true)
this.logger.error.called.should.equal(false)
this.logger.warn.called.should.equal(false)
})
})
)
return describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.get = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 }, '')
return this.DocumentUpdaterManager.getDocument(
this.project_id,
this.doc_id,
this.fromVersion,
this.callback
)
})
return it('should return the callback with an error', function () {
this.callback.called.should.equal(true)
this.callback
.calledWith(
sinon.match({
message: 'doc updater returned a non-success status code',
info: {
action: 'getDocument',
statusCode: 500,
},
})
)
.should.equal(true)
this.logger.error.called.should.equal(false)
})
})
})
describe('flushProjectToMongoAndDelete', function () {
beforeEach(function () {
return (this.callback = sinon.stub())
})
describe('successfully', function () {
beforeEach(function () {
this.request.del = sinon
.stub()
.callsArgWith(1, null, { statusCode: 204 }, '')
return this.DocumentUpdaterManager.flushProjectToMongoAndDelete(
this.project_id,
this.callback
)
})
it('should delete the project from the document updater', function () {
const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}?background=true`
return this.request.del.calledWith(url).should.equal(true)
})
return it('should call the callback with no error', function () {
return this.callback.calledWith(null).should.equal(true)
})
})
describe('when the document updater API returns an error', function () {
beforeEach(function () {
this.request.del = sinon
.stub()
.callsArgWith(
1,
(this.error = new Error('something went wrong')),
null,
null
)
return this.DocumentUpdaterManager.flushProjectToMongoAndDelete(
this.project_id,
this.callback
)
})
return it('should return an error to the callback', function () {
return this.callback.calledWith(this.error).should.equal(true)
})
})
return describe('when the document updater returns a failure error code', function () {
beforeEach(function () {
this.request.del = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 }, '')
return this.DocumentUpdaterManager.flushProjectToMongoAndDelete(
this.project_id,
this.callback
)
})
return it('should return the callback with an error', function () {
this.callback.called.should.equal(true)
this.callback
.calledWith(
sinon.match({
message: 'doc updater returned a non-success status code',
info: {
action: 'flushProjectToMongoAndDelete',
statusCode: 500,
},
})
)
.should.equal(true)
})
})
})
describe('queueChange', function () {
beforeEach(function () {
this.change = {
doc: '1234567890',
op: [{ d: 'test', p: 345 }],
v: 789,
}
this.rclient.rpush = sinon.stub().yields()
return (this.callback = sinon.stub())
})
describe('successfully', function () {
beforeEach(function () {
this.pendingUpdateListKey = `pending-updates-list-key-${Math.random()}`
this.DocumentUpdaterManager._getPendingUpdateListKey = sinon
.stub()
.returns(this.pendingUpdateListKey)
this.DocumentUpdaterManager.queueChange(
this.project_id,
this.doc_id,
this.change,
this.callback
)
})
it('should push the change', function () {
this.rclient.rpush
.calledWith(
`PendingUpdates:${this.doc_id}`,
JSON.stringify(this.change)
)
.should.equal(true)
})
it('should notify the doc updater of the change via the pending-updates-list queue', function () {
this.rclient.rpush
.calledWith(
this.pendingUpdateListKey,
`${this.project_id}:${this.doc_id}`
)
.should.equal(true)
})
})
describe('with error talking to redis during rpush', function () {
beforeEach(function () {
this.rclient.rpush = sinon
.stub()
.yields(new Error('something went wrong'))
return this.DocumentUpdaterManager.queueChange(
this.project_id,
this.doc_id,
this.change,
this.callback
)
})
return it('should return an error', function () {
return this.callback
.calledWithExactly(sinon.match(Error))
.should.equal(true)
})
})
describe('with null byte corruption', function () {
beforeEach(function () {
this.stringifyStub = sinon
.stub(JSON, 'stringify')
.callsFake(() => '["bad bytes! \u0000 <- here"]')
return this.DocumentUpdaterManager.queueChange(
this.project_id,
this.doc_id,
this.change,
this.callback
)
})
afterEach(function () {
this.stringifyStub.restore()
})
it('should return an error', function () {
return this.callback
.calledWithExactly(sinon.match(Error))
.should.equal(true)
})
return it('should not push the change onto the pending-updates-list queue', function () {
return this.rclient.rpush.called.should.equal(false)
})
})
describe('when the update is too large', function () {
beforeEach(function () {
this.change = {
op: { p: 12, t: 'update is too large'.repeat(1024 * 400) },
}
return this.DocumentUpdaterManager.queueChange(
this.project_id,
this.doc_id,
this.change,
this.callback
)
})
it('should return an error', function () {
return this.callback
.calledWithExactly(sinon.match(Error))
.should.equal(true)
})
it('should add the size to the error', function () {
return this.callback.args[0][0].info.updateSize.should.equal(7782422)
})
return it('should not push the change onto the pending-updates-list queue', function () {
return this.rclient.rpush.called.should.equal(false)
})
})
describe('with invalid keys', function () {
beforeEach(function () {
this.change = {
op: [{ d: 'test', p: 345 }],
version: 789, // not a valid key
}
return this.DocumentUpdaterManager.queueChange(
this.project_id,
this.doc_id,
this.change,
this.callback
)
})
it('should remove the invalid keys from the change', function () {
return this.rclient.rpush
.calledWith(
`PendingUpdates:${this.doc_id}`,
JSON.stringify({ op: this.change.op })
)
.should.equal(true)
})
})
})
describe('_getPendingUpdateListKey', function () {
beforeEach(function () {
const keys = _.times(
10000,
this.DocumentUpdaterManager._getPendingUpdateListKey
)
this.keys = _.uniq(keys)
})
it('should return normal pending updates key', function () {
_.includes(this.keys, 'pending-updates-list').should.equal(true)
})
it('should return pending-updates-list-n keys', function () {
_.includes(this.keys, 'pending-updates-list-1').should.equal(true)
_.includes(this.keys, 'pending-updates-list-3').should.equal(true)
_.includes(this.keys, 'pending-updates-list-9').should.equal(true)
})
it('should not include pending-updates-list-0 key', function () {
_.includes(this.keys, 'pending-updates-list-0').should.equal(false)
})
it('should not include maximum as pendingUpdateListShardCount value', function () {
_.includes(this.keys, 'pending-updates-list-10').should.equal(false)
})
})
})

View File

@@ -0,0 +1,127 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const SandboxedModule = require('sandboxed-module')
const path = require('node:path')
const modulePath = path.join(__dirname, '../../../app/js/DrainManager')
describe('DrainManager', function () {
beforeEach(function () {
this.DrainManager = SandboxedModule.require(modulePath, {})
return (this.io = {
sockets: {
clients: sinon.stub(),
},
})
})
describe('startDrainTimeWindow', function () {
beforeEach(function () {
this.clients = []
for (let i = 0; i <= 5399; i++) {
this.clients[i] = {
id: i,
emit: sinon.stub(),
}
}
this.io.sockets.clients.returns(this.clients)
return (this.DrainManager.startDrain = sinon.stub())
})
return it('should set a drain rate fast enough', function (done) {
this.DrainManager.startDrainTimeWindow(this.io, 9)
this.DrainManager.startDrain.calledWith(this.io, 10).should.equal(true)
return done()
})
})
return describe('reconnectNClients', function () {
beforeEach(function () {
this.clients = []
for (let i = 0; i <= 9; i++) {
this.clients[i] = {
id: i,
emit: sinon.stub(),
}
}
return this.io.sockets.clients.returns(this.clients)
})
return describe('after first pass', function () {
beforeEach(function () {
return this.DrainManager.reconnectNClients(this.io, 3)
})
it('should reconnect the first 3 clients', function () {
return [0, 1, 2].map(i =>
this.clients[i].emit
.calledWith('reconnectGracefully')
.should.equal(true)
)
})
it('should not reconnect any more clients', function () {
return [3, 4, 5, 6, 7, 8, 9].map(i =>
this.clients[i].emit
.calledWith('reconnectGracefully')
.should.equal(false)
)
})
return describe('after second pass', function () {
beforeEach(function () {
return this.DrainManager.reconnectNClients(this.io, 3)
})
it('should reconnect the next 3 clients', function () {
return [3, 4, 5].map(i =>
this.clients[i].emit
.calledWith('reconnectGracefully')
.should.equal(true)
)
})
it('should not reconnect any more clients', function () {
return [6, 7, 8, 9].map(i =>
this.clients[i].emit
.calledWith('reconnectGracefully')
.should.equal(false)
)
})
it('should not reconnect the first 3 clients again', function () {
return [0, 1, 2].map(i =>
this.clients[i].emit.calledOnce.should.equal(true)
)
})
return describe('after final pass', function () {
beforeEach(function () {
return this.DrainManager.reconnectNClients(this.io, 100)
})
it('should not reconnect the first 6 clients again', function () {
return [0, 1, 2, 3, 4, 5].map(i =>
this.clients[i].emit.calledOnce.should.equal(true)
)
})
return it('should log out that it reached the end', function () {
return this.logger.info
.calledWith('All clients have been told to reconnectGracefully')
.should.equal(true)
})
})
})
})
})
})

View File

@@ -0,0 +1,153 @@
/* eslint-disable
no-return-assign,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const modulePath = '../../../app/js/EventLogger'
const sinon = require('sinon')
const tk = require('timekeeper')
describe('EventLogger', function () {
beforeEach(function () {
this.start = Date.now()
tk.freeze(new Date(this.start))
this.EventLogger = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/metrics': (this.metrics = { inc: sinon.stub() }),
},
})
this.channel = 'applied-ops'
this.id_1 = 'random-hostname:abc-1'
this.message_1 = 'message-1'
this.id_2 = 'random-hostname:abc-2'
return (this.message_2 = 'message-2')
})
afterEach(function () {
return tk.reset()
})
return describe('checkEventOrder', function () {
describe('when the events are in order', function () {
beforeEach(function () {
this.EventLogger.checkEventOrder(
this.channel,
this.id_1,
this.message_1
)
return (this.status = this.EventLogger.checkEventOrder(
this.channel,
this.id_2,
this.message_2
))
})
it('should accept events in order', function () {
return expect(this.status).to.be.undefined
})
return it('should increment the valid event metric', function () {
return this.metrics.inc
.calledWith(`event.${this.channel}.valid`)
.should.equals(true)
})
})
describe('when there is a duplicate events', function () {
beforeEach(function () {
this.EventLogger.checkEventOrder(
this.channel,
this.id_1,
this.message_1
)
return (this.status = this.EventLogger.checkEventOrder(
this.channel,
this.id_1,
this.message_1
))
})
it('should return "duplicate" for the same event', function () {
return expect(this.status).to.equal('duplicate')
})
return it('should increment the duplicate event metric', function () {
return this.metrics.inc
.calledWith(`event.${this.channel}.duplicate`)
.should.equals(true)
})
})
describe('when there are out of order events', function () {
beforeEach(function () {
this.EventLogger.checkEventOrder(
this.channel,
this.id_1,
this.message_1
)
this.EventLogger.checkEventOrder(
this.channel,
this.id_2,
this.message_2
)
return (this.status = this.EventLogger.checkEventOrder(
this.channel,
this.id_1,
this.message_1
))
})
it('should return "out-of-order" for the event', function () {
return expect(this.status).to.equal('out-of-order')
})
return it('should increment the out-of-order event metric', function () {
return this.metrics.inc
.calledWith(`event.${this.channel}.out-of-order`)
.should.equals(true)
})
})
return describe('after MAX_STALE_TIME_IN_MS', function () {
return it('should flush old entries', function () {
let status
this.EventLogger.MAX_EVENTS_BEFORE_CLEAN = 10
this.EventLogger.checkEventOrder(
this.channel,
this.id_1,
this.message_1
)
for (let i = 1; i <= 8; i++) {
status = this.EventLogger.checkEventOrder(
this.channel,
this.id_1,
this.message_1
)
expect(status).to.equal('duplicate')
}
// the next event should flush the old entries aboce
this.EventLogger.MAX_STALE_TIME_IN_MS = 1000
tk.freeze(new Date(this.start + 5 * 1000))
// because we flushed the entries this should not be a duplicate
this.EventLogger.checkEventOrder(
this.channel,
'other-1',
this.message_2
)
status = this.EventLogger.checkEventOrder(
this.channel,
this.id_1,
this.message_1
)
return expect(status).to.be.undefined
})
})
})
})

View File

@@ -0,0 +1,412 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
promise/param-names,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const { expect } = require('chai')
const sinon = require('sinon')
const modulePath = '../../../app/js/RoomManager.js'
const SandboxedModule = require('sandboxed-module')
describe('RoomManager', function () {
beforeEach(function () {
this.project_id = 'project-id-123'
this.doc_id = 'doc-id-456'
this.other_doc_id = 'doc-id-789'
this.client = { namespace: { name: '' }, id: 'first-client' }
this.RoomManager = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.settings = {}),
'@overleaf/metrics': (this.metrics = { gauge: sinon.stub() }),
},
})
this.RoomManager._clientsInRoom = sinon.stub()
this.RoomManager._clientAlreadyInRoom = sinon.stub()
this.RoomEvents = this.RoomManager.eventSource()
sinon.spy(this.RoomEvents, 'emit')
return sinon.spy(this.RoomEvents, 'once')
})
describe('emitOnCompletion', function () {
return describe('when a subscribe errors', function () {
afterEach(function () {
return process.removeListener('unhandledRejection', this.onUnhandled)
})
beforeEach(function (done) {
this.onUnhandled = error => {
this.unhandledError = error
return done(new Error(`unhandledRejection: ${error.message}`))
}
process.on('unhandledRejection', this.onUnhandled)
let reject
const subscribePromise = new Promise((_, r) => (reject = r))
const promises = [subscribePromise]
const eventName = 'project-subscribed-123'
this.RoomEvents.once(eventName, () => setTimeout(done, 100))
this.RoomManager.emitOnCompletion(promises, eventName)
return setTimeout(() => reject(new Error('subscribe failed')))
})
return it('should keep going', function () {
return expect(this.unhandledError).to.not.exist
})
})
})
describe('joinProject', function () {
describe('when the project room is empty', function () {
beforeEach(function (done) {
this.RoomManager._clientsInRoom
.withArgs(this.client, this.project_id)
.onFirstCall()
.returns(0)
this.client.join = sinon.stub()
this.callback = sinon.stub()
this.RoomEvents.on('project-active', id => {
return setTimeout(() => {
return this.RoomEvents.emit(`project-subscribed-${id}`)
}, 100)
})
return this.RoomManager.joinProject(
this.client,
this.project_id,
err => {
this.callback(err)
return done()
}
)
})
it("should emit a 'project-active' event with the id", function () {
return this.RoomEvents.emit
.calledWithExactly('project-active', this.project_id)
.should.equal(true)
})
it("should listen for the 'project-subscribed-id' event", function () {
return this.RoomEvents.once
.calledWith(`project-subscribed-${this.project_id}`)
.should.equal(true)
})
return it('should join the room using the id', function () {
return this.client.join
.calledWithExactly(this.project_id)
.should.equal(true)
})
})
return describe('when there are other clients in the project room', function () {
beforeEach(function (done) {
this.RoomManager._clientsInRoom
.withArgs(this.client, this.project_id)
.onFirstCall()
.returns(123)
.onSecondCall()
.returns(124)
this.client.join = sinon.stub()
this.RoomManager.joinProject(this.client, this.project_id, done)
})
it('should join the room using the id', function () {
return this.client.join.called.should.equal(true)
})
return it('should not emit any events', function () {
return this.RoomEvents.emit.called.should.equal(false)
})
})
})
describe('joinDoc', function () {
describe('when the doc room is empty', function () {
beforeEach(function (done) {
this.RoomManager._clientsInRoom
.withArgs(this.client, this.doc_id)
.onFirstCall()
.returns(0)
this.client.join = sinon.stub()
this.callback = sinon.stub()
this.RoomEvents.on('doc-active', id => {
return setTimeout(() => {
return this.RoomEvents.emit(`doc-subscribed-${id}`)
}, 100)
})
return this.RoomManager.joinDoc(this.client, this.doc_id, err => {
this.callback(err)
return done()
})
})
it("should emit a 'doc-active' event with the id", function () {
return this.RoomEvents.emit
.calledWithExactly('doc-active', this.doc_id)
.should.equal(true)
})
it("should listen for the 'doc-subscribed-id' event", function () {
return this.RoomEvents.once
.calledWith(`doc-subscribed-${this.doc_id}`)
.should.equal(true)
})
return it('should join the room using the id', function () {
return this.client.join
.calledWithExactly(this.doc_id)
.should.equal(true)
})
})
return describe('when there are other clients in the doc room', function () {
beforeEach(function (done) {
this.RoomManager._clientsInRoom
.withArgs(this.client, this.doc_id)
.onFirstCall()
.returns(123)
.onSecondCall()
.returns(124)
this.client.join = sinon.stub()
this.RoomManager.joinDoc(this.client, this.doc_id, done)
})
it('should join the room using the id', function () {
return this.client.join.called.should.equal(true)
})
return it('should not emit any events', function () {
return this.RoomEvents.emit.called.should.equal(false)
})
})
})
describe('leaveDoc', function () {
describe('when doc room will be empty after this client has left', function () {
beforeEach(function () {
this.RoomManager._clientAlreadyInRoom
.withArgs(this.client, this.doc_id)
.returns(true)
this.RoomManager._clientsInRoom
.withArgs(this.client, this.doc_id)
.onCall(0)
.returns(0)
this.client.leave = sinon.stub()
return this.RoomManager.leaveDoc(this.client, this.doc_id)
})
it('should leave the room using the id', function () {
return this.client.leave
.calledWithExactly(this.doc_id)
.should.equal(true)
})
return it("should emit a 'doc-empty' event with the id", function () {
return this.RoomEvents.emit
.calledWithExactly('doc-empty', this.doc_id)
.should.equal(true)
})
})
describe('when there are other clients in the doc room', function () {
beforeEach(function () {
this.RoomManager._clientAlreadyInRoom
.withArgs(this.client, this.doc_id)
.returns(true)
this.RoomManager._clientsInRoom
.withArgs(this.client, this.doc_id)
.onCall(0)
.returns(123)
this.client.leave = sinon.stub()
return this.RoomManager.leaveDoc(this.client, this.doc_id)
})
it('should leave the room using the id', function () {
return this.client.leave
.calledWithExactly(this.doc_id)
.should.equal(true)
})
return it('should not emit any events', function () {
return this.RoomEvents.emit.called.should.equal(false)
})
})
return describe('when the client is not in the doc room', function () {
beforeEach(function () {
this.RoomManager._clientAlreadyInRoom
.withArgs(this.client, this.doc_id)
.returns(false)
this.RoomManager._clientsInRoom
.withArgs(this.client, this.doc_id)
.onCall(0)
.returns(0)
this.client.leave = sinon.stub()
return this.RoomManager.leaveDoc(this.client, this.doc_id)
})
it('should not leave the room', function () {
return this.client.leave.called.should.equal(false)
})
return it('should not emit any events', function () {
return this.RoomEvents.emit.called.should.equal(false)
})
})
})
return describe('leaveProjectAndDocs', function () {
return describe('when the client is connected to the project and multiple docs', function () {
beforeEach(function () {
this.RoomManager._roomsClientIsIn = sinon
.stub()
.returns([this.project_id, this.doc_id, this.other_doc_id])
this.client.join = sinon.stub()
return (this.client.leave = sinon.stub())
})
describe('when this is the only client connected', function () {
beforeEach(function (done) {
// first call is for the join,
// second for the leave
this.RoomManager._clientsInRoom
.withArgs(this.client, this.doc_id)
.onCall(0)
.returns(0)
.onCall(1)
.returns(0)
this.RoomManager._clientsInRoom
.withArgs(this.client, this.other_doc_id)
.onCall(0)
.returns(0)
.onCall(1)
.returns(0)
this.RoomManager._clientsInRoom
.withArgs(this.client, this.project_id)
.onCall(0)
.returns(0)
.onCall(1)
.returns(0)
this.RoomManager._clientAlreadyInRoom
.withArgs(this.client, this.doc_id)
.returns(true)
.withArgs(this.client, this.other_doc_id)
.returns(true)
.withArgs(this.client, this.project_id)
.returns(true)
this.RoomEvents.on('project-active', id => {
return setTimeout(() => {
return this.RoomEvents.emit(`project-subscribed-${id}`)
}, 100)
})
this.RoomEvents.on('doc-active', id => {
return setTimeout(() => {
return this.RoomEvents.emit(`doc-subscribed-${id}`)
}, 100)
})
// put the client in the rooms
return this.RoomManager.joinProject(
this.client,
this.project_id,
() => {
return this.RoomManager.joinDoc(this.client, this.doc_id, () => {
return this.RoomManager.joinDoc(
this.client,
this.other_doc_id,
() => {
// now leave the project
this.RoomManager.leaveProjectAndDocs(this.client)
return done()
}
)
})
}
)
})
it('should leave all the docs', function () {
this.client.leave.calledWithExactly(this.doc_id).should.equal(true)
return this.client.leave
.calledWithExactly(this.other_doc_id)
.should.equal(true)
})
it('should leave the project', function () {
return this.client.leave
.calledWithExactly(this.project_id)
.should.equal(true)
})
it("should emit a 'doc-empty' event with the id for each doc", function () {
this.RoomEvents.emit
.calledWithExactly('doc-empty', this.doc_id)
.should.equal(true)
return this.RoomEvents.emit
.calledWithExactly('doc-empty', this.other_doc_id)
.should.equal(true)
})
return it("should emit a 'project-empty' event with the id for the project", function () {
return this.RoomEvents.emit
.calledWithExactly('project-empty', this.project_id)
.should.equal(true)
})
})
return describe('when other clients are still connected', function () {
beforeEach(function () {
this.RoomManager._clientsInRoom
.withArgs(this.client, this.doc_id)
.onFirstCall()
.returns(123)
.onSecondCall()
.returns(122)
this.RoomManager._clientsInRoom
.withArgs(this.client, this.other_doc_id)
.onFirstCall()
.returns(123)
.onSecondCall()
.returns(122)
this.RoomManager._clientsInRoom
.withArgs(this.client, this.project_id)
.onFirstCall()
.returns(123)
.onSecondCall()
.returns(122)
this.RoomManager._clientAlreadyInRoom
.withArgs(this.client, this.doc_id)
.returns(true)
.withArgs(this.client, this.other_doc_id)
.returns(true)
.withArgs(this.client, this.project_id)
.returns(true)
return this.RoomManager.leaveProjectAndDocs(this.client)
})
it('should leave all the docs', function () {
this.client.leave.calledWithExactly(this.doc_id).should.equal(true)
return this.client.leave
.calledWithExactly(this.other_doc_id)
.should.equal(true)
})
it('should leave the project', function () {
return this.client.leave
.calledWithExactly(this.project_id)
.should.equal(true)
})
return it('should not emit any events', function () {
return this.RoomEvents.emit.called.should.equal(false)
})
})
})
})
})

View File

@@ -0,0 +1,55 @@
/* eslint-disable
no-return-assign,
no-useless-escape,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const modulePath = '../../../app/js/SafeJsonParse'
describe('SafeJsonParse', function () {
beforeEach(function () {
return (this.SafeJsonParse = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.Settings = {
maxUpdateSize: 16 * 1024,
}),
},
}))
})
return describe('parse', function () {
it('should parse documents correctly', function (done) {
return this.SafeJsonParse.parse('{"foo": "bar"}', (error, parsed) => {
if (error) return done(error)
expect(parsed).to.deep.equal({ foo: 'bar' })
return done()
})
})
it('should return an error on bad data', function (done) {
return this.SafeJsonParse.parse('blah', (error, parsed) => {
expect(error).to.exist
return done()
})
})
return it('should return an error on oversized data', function (done) {
// we have a 2k overhead on top of max size
const bigBlob = Array(16 * 1024).join('A')
const data = `{\"foo\": \"${bigBlob}\"}`
this.Settings.maxUpdateSize = 2 * 1024
return this.SafeJsonParse.parse(data, (error, parsed) => {
this.logger.error.called.should.equal(false)
expect(error).to.exist
return done()
})
})
})
})

View File

@@ -0,0 +1,280 @@
/* eslint-disable
no-return-assign,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const { EventEmitter } = require('node:events')
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const modulePath = '../../../app/js/SessionSockets'
const sinon = require('sinon')
describe('SessionSockets', function () {
beforeEach(function () {
this.metrics = { inc: sinon.stub() }
this.SessionSocketsModule = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/metrics': this.metrics,
},
})
this.io = new EventEmitter()
this.id1 = Math.random().toString()
this.id2 = Math.random().toString()
const redisResponses = {
error: [new Error('Redis: something went wrong'), null],
unknownId: [null, null],
}
redisResponses[this.id1] = [null, { user: { _id: '123' } }]
redisResponses[this.id2] = [null, { user: { _id: 'abc' } }]
this.sessionStore = {
get: sinon
.stub()
.callsFake((id, fn) => fn.apply(null, redisResponses[id])),
}
this.cookieParser = function (req, res, next) {
req.signedCookies = req._signedCookies
return next()
}
this.SessionSockets = this.SessionSocketsModule(
this.io,
this.sessionStore,
this.cookieParser,
'ol.sid'
)
return (this.checkSocket = (socket, fn) => {
this.SessionSockets.once('connection', fn)
return this.io.emit('connection', socket)
})
})
describe('without cookies', function () {
beforeEach(function () {
return (this.socket = { handshake: {} })
})
it('should return a lookup error', function (done) {
return this.checkSocket(this.socket, error => {
expect(error).to.exist
expect(error.message).to.equal('could not look up session by key')
return done()
})
})
it('should not query redis', function (done) {
return this.checkSocket(this.socket, () => {
expect(this.sessionStore.get.called).to.equal(false)
return done()
})
})
it('should increment the session.cookie metric with status "none"', function (done) {
return this.checkSocket(this.socket, () => {
expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
status: 'none',
})
return done()
})
})
})
describe('with a different cookie', function () {
beforeEach(function () {
return (this.socket = { handshake: { _signedCookies: { other: 1 } } })
})
it('should return a lookup error', function (done) {
return this.checkSocket(this.socket, error => {
expect(error).to.exist
expect(error.message).to.equal('could not look up session by key')
return done()
})
})
it('should not query redis', function (done) {
return this.checkSocket(this.socket, () => {
expect(this.sessionStore.get.called).to.equal(false)
return done()
})
})
})
describe('with a cookie with an invalid signature', function () {
beforeEach(function () {
return (this.socket = {
handshake: { _signedCookies: { 'ol.sid': false } },
})
})
it('should return a lookup error', function (done) {
return this.checkSocket(this.socket, error => {
expect(error).to.exist
expect(error.message).to.equal('could not look up session by key')
return done()
})
})
it('should not query redis', function (done) {
return this.checkSocket(this.socket, () => {
expect(this.sessionStore.get.called).to.equal(false)
return done()
})
})
it('should increment the session.cookie metric with status=bad-signature', function (done) {
return this.checkSocket(this.socket, () => {
expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
status: 'bad-signature',
})
return done()
})
})
})
describe('with a valid cookie and a failing session lookup', function () {
beforeEach(function () {
return (this.socket = {
handshake: { _signedCookies: { 'ol.sid': 'error' } },
})
})
it('should query redis', function (done) {
return this.checkSocket(this.socket, () => {
expect(this.sessionStore.get.called).to.equal(true)
return done()
})
})
it('should return a redis error', function (done) {
return this.checkSocket(this.socket, error => {
expect(error).to.exist
expect(error.message).to.equal('Redis: something went wrong')
return done()
})
})
it('should increment the session.cookie metric with status=error', function (done) {
return this.checkSocket(this.socket, () => {
expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
status: 'error',
})
return done()
})
})
})
describe('with a valid cookie and no matching session', function () {
beforeEach(function () {
return (this.socket = {
handshake: { _signedCookies: { 'ol.sid': 'unknownId' } },
})
})
it('should query redis', function (done) {
return this.checkSocket(this.socket, () => {
expect(this.sessionStore.get.called).to.equal(true)
return done()
})
})
it('should return a lookup error', function (done) {
return this.checkSocket(this.socket, error => {
expect(error).to.exist
expect(error.message).to.equal('could not look up session by key')
return done()
})
})
it('should increment the session.cookie metric with status=missing', function (done) {
return this.checkSocket(this.socket, () => {
expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
status: 'missing',
})
return done()
})
})
})
describe('with a valid cookie and a matching session', function () {
beforeEach(function () {
return (this.socket = {
handshake: { _signedCookies: { 'ol.sid': this.id1 } },
})
})
it('should query redis', function (done) {
return this.checkSocket(this.socket, () => {
expect(this.sessionStore.get.called).to.equal(true)
return done()
})
})
it('should not return an error', function (done) {
return this.checkSocket(this.socket, error => {
expect(error).to.not.exist
return done()
})
})
it('should return the session', function (done) {
return this.checkSocket(this.socket, (error, s, session) => {
if (error) return done(error)
expect(session).to.deep.equal({ user: { _id: '123' } })
return done()
})
})
it('should increment the session.cookie metric with status=signed', function (done) {
return this.checkSocket(this.socket, () => {
expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
status: 'signed',
})
return done()
})
})
})
describe('with a different valid cookie and matching session', function () {
beforeEach(function () {
return (this.socket = {
handshake: { _signedCookies: { 'ol.sid': this.id2 } },
})
})
it('should query redis', function (done) {
return this.checkSocket(this.socket, () => {
expect(this.sessionStore.get.called).to.equal(true)
return done()
})
})
it('should not return an error', function (done) {
return this.checkSocket(this.socket, error => {
expect(error).to.not.exist
return done()
})
})
it('should return the other session', function (done) {
return this.checkSocket(this.socket, (error, s, session) => {
if (error) return done(error)
expect(session).to.deep.equal({ user: { _id: 'abc' } })
return done()
})
})
it('should increment the session.cookie metric with status=error', function (done) {
return this.checkSocket(this.socket, () => {
expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
status: 'signed',
})
return done()
})
})
})
})

View File

@@ -0,0 +1,268 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const sinon = require('sinon')
const modulePath = '../../../app/js/WebApiManager.js'
const SandboxedModule = require('sandboxed-module')
const { CodedError } = require('../../../app/js/Errors')
describe('WebApiManager', function () {
beforeEach(function () {
this.project_id = 'project-id-123'
this.user_id = 'user-id-123'
this.user = { _id: this.user_id }
this.callback = sinon.stub()
return (this.WebApiManager = SandboxedModule.require(modulePath, {
requires: {
request: (this.request = {}),
'@overleaf/settings': (this.settings = {
apis: {
web: {
url: 'http://web.example.com',
user: 'username',
pass: 'password',
},
},
}),
},
}))
})
return describe('joinProject', function () {
describe('successfully', function () {
beforeEach(function () {
this.response = {
project: { name: 'Test project' },
privilegeLevel: 'owner',
isRestrictedUser: true,
isTokenMember: true,
isInvitedMember: true,
}
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 200 }, this.response)
return this.WebApiManager.joinProject(
this.project_id,
this.user,
this.callback
)
})
it('should send a request to web to join the project', function () {
return this.request.post
.calledWith({
url: `${this.settings.apis.web.url}/project/${this.project_id}/join`,
auth: {
user: this.settings.apis.web.user,
pass: this.settings.apis.web.pass,
sendImmediately: true,
},
json: {
userId: this.user_id,
anonymousAccessToken: undefined,
},
jar: false,
})
.should.equal(true)
})
return it('should return the project, privilegeLevel, and restricted flag', function () {
return this.callback
.calledWith(
null,
this.response.project,
this.response.privilegeLevel,
{
isRestrictedUser: this.response.isRestrictedUser,
isTokenMember: this.response.isTokenMember,
isInvitedMember: this.response.isInvitedMember,
}
)
.should.equal(true)
})
})
describe('with anon user', function () {
beforeEach(function () {
this.user_id = 'anonymous-user'
this.token = 'a-ro-token'
this.user = {
_id: this.user_id,
anonymousAccessToken: this.token,
}
this.response = {
project: { name: 'Test project' },
privilegeLevel: 'readOnly',
isRestrictedUser: true,
isTokenMember: false,
isInvitedMember: false,
}
this.request.post = sinon
.stub()
.yields(null, { statusCode: 200 }, this.response)
this.WebApiManager.joinProject(
this.project_id,
this.user,
this.callback
)
})
it('should send a request to web to join the project', function () {
this.request.post.should.have.been.calledWith({
url: `${this.settings.apis.web.url}/project/${this.project_id}/join`,
auth: {
user: this.settings.apis.web.user,
pass: this.settings.apis.web.pass,
sendImmediately: true,
},
json: {
userId: this.user_id,
anonymousAccessToken: this.token,
},
jar: false,
})
})
it('should return the project, privilegeLevel, and restricted flag', function () {
this.callback.should.have.been.calledWith(
null,
this.response.project,
this.response.privilegeLevel,
{
isRestrictedUser: this.response.isRestrictedUser,
isTokenMember: this.response.isTokenMember,
isInvitedMember: this.response.isInvitedMember,
}
)
})
})
describe('when web replies with a 403', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 403 }, null)
this.WebApiManager.joinProject(
this.project_id,
this.user_id,
this.callback
)
})
it('should call the callback with an error', function () {
this.callback
.calledWith(
sinon.match({
message: 'not authorized',
})
)
.should.equal(true)
})
})
describe('when web replies with a 404', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 404 }, null)
this.WebApiManager.joinProject(
this.project_id,
this.user_id,
this.callback
)
})
it('should call the callback with an error', function () {
this.callback
.calledWith(
sinon.match({
message: 'project not found',
info: { code: 'ProjectNotFound' },
})
)
.should.equal(true)
})
})
describe('with an error from web', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 500 }, null)
return this.WebApiManager.joinProject(
this.project_id,
this.user_id,
this.callback
)
})
return it('should call the callback with an error', function () {
return this.callback
.calledWith(
sinon.match({
message: 'non-success status code from web',
info: { statusCode: 500 },
})
)
.should.equal(true)
})
})
describe('with no data from web', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 200 }, null)
return this.WebApiManager.joinProject(
this.project_id,
this.user_id,
this.callback
)
})
return it('should call the callback with an error', function () {
return this.callback
.calledWith(
sinon.match({
message: 'no data returned from joinProject request',
})
)
.should.equal(true)
})
})
return describe('when the project is over its rate limit', function () {
beforeEach(function () {
this.request.post = sinon
.stub()
.callsArgWith(1, null, { statusCode: 429 }, null)
return this.WebApiManager.joinProject(
this.project_id,
this.user_id,
this.callback
)
})
return it('should call the callback with a TooManyRequests error code', function () {
return this.callback
.calledWith(
sinon.match({
message: 'rate-limit hit when joining project',
info: {
code: 'TooManyRequests',
},
})
)
.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,100 @@
const SandboxedModule = require('sandboxed-module')
const { expect } = require('chai')
const modulePath = require('node:path').join(
__dirname,
'../../../app/js/WebsocketAddressManager'
)
describe('WebsocketAddressManager', function () {
beforeEach(function () {
this.WebsocketAddressManager = SandboxedModule.require(modulePath, {
requires: {},
})
})
describe('with a proxy configuration', function () {
beforeEach(function () {
this.websocketAddressManager = new this.WebsocketAddressManager(
true,
'127.0.0.1'
)
})
it('should return the client ip address when behind a proxy', function () {
expect(
this.websocketAddressManager.getRemoteIp({
headers: {
'x-forwarded-proto': 'https',
'x-forwarded-for': '123.45.67.89',
},
address: { address: '127.0.0.1' },
})
).to.equal('123.45.67.89')
})
it('should return the client ip address for a direct connection', function () {
expect(
this.websocketAddressManager.getRemoteIp({
headers: {},
address: { address: '123.45.67.89' },
})
).to.equal('123.45.67.89')
})
it('should return the client ip address when there are no headers in the handshake', function () {
expect(
this.websocketAddressManager.getRemoteIp({
address: { address: '123.45.67.89' },
})
).to.equal('123.45.67.89')
})
it('should return a "client-handshake-missing" response when the handshake is missing', function () {
expect(this.websocketAddressManager.getRemoteIp()).to.equal(
'client-handshake-missing'
)
})
})
describe('without a proxy configuration', function () {
beforeEach(function () {
this.websocketAddressManager = new this.WebsocketAddressManager(false)
})
it('should return the client ip address for a direct connection', function () {
expect(
this.websocketAddressManager.getRemoteIp({
headers: {},
address: { address: '123.45.67.89' },
})
).to.equal('123.45.67.89')
})
it('should return undefined if the client ip address is not present', function () {
expect(
this.websocketAddressManager.getRemoteIp({
headers: {},
address: { otherAddressProperty: '123.45.67.89' },
})
).to.be.undefined
})
it('should return the proxy ip address if there is actually a proxy', function () {
expect(
this.websocketAddressManager.getRemoteIp({
headers: {
'x-forwarded-proto': 'https',
'x-forwarded-for': '123.45.67.89',
},
address: { address: '127.0.0.1' },
})
).to.equal('127.0.0.1')
})
it('should return a "client-handshake-missing" response when the handshake is missing', function () {
expect(this.websocketAddressManager.getRemoteIp()).to.equal(
'client-handshake-missing'
)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,514 @@
/* eslint-disable
no-return-assign,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const expect = require('chai').expect
const modulePath = require('node:path').join(
__dirname,
'../../../app/js/WebsocketLoadBalancer'
)
describe('WebsocketLoadBalancer', function () {
beforeEach(function () {
this.rclient = {}
this.RoomEvents = { on: sinon.stub() }
this.WebsocketLoadBalancer = SandboxedModule.require(modulePath, {
requires: {
'@overleaf/settings': (this.Settings = { redis: {} }),
'./RedisClientManager': {
createClientList: () => [],
},
'./SafeJsonParse': (this.SafeJsonParse = {
parse: (data, cb) => cb(null, JSON.parse(data)),
}),
'./EventLogger': { checkEventOrder: sinon.stub() },
'./HealthCheckManager': { check: sinon.stub() },
'./RoomManager': (this.RoomManager = {
eventSource: sinon.stub().returns(this.RoomEvents),
}),
'./ChannelManager': (this.ChannelManager = { publish: sinon.stub() }),
'./ConnectedUsersManager': (this.ConnectedUsersManager = {
refreshClient: sinon.stub(),
}),
},
})
this.io = {}
this.WebsocketLoadBalancer.rclientPubList = [{ publish: sinon.stub() }]
this.WebsocketLoadBalancer.rclientSubList = [
{
subscribe: sinon.stub(),
on: sinon.stub(),
},
]
this.room_id = 'room-id'
this.message = 'otUpdateApplied'
return (this.payload = ['argument one', 42])
})
describe('shouldDisconnectClient', function () {
it('should return false for general messages', function () {
const client = {
ol_context: { user_id: 'abcd' },
}
const message = {
message: 'someNiceMessage',
payload: [{ data: 'whatever' }],
}
expect(
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
).to.equal(false)
})
describe('collaborator access level changed', function () {
const messageName = 'project:collaboratorAccessLevel:changed'
const client = {
ol_context: { user_id: 'abcd' },
}
it('should return true if the user id matches', function () {
const message = {
message: messageName,
payload: [
{
userId: 'abcd',
},
],
}
expect(
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
).to.equal(true)
})
it('should return false if the user id does not match', function () {
const message = {
message: messageName,
payload: [
{
userId: 'xyz',
},
],
}
expect(
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
).to.equal(false)
})
})
describe('user removed from project', function () {
const messageName = 'userRemovedFromProject'
const client = {
ol_context: { user_id: 'abcd' },
}
it('should return false, when the user_id does not match', function () {
const message = {
message: messageName,
payload: ['xyz'],
}
expect(
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
).to.equal(false)
})
it('should return true, if the user_id matches', function () {
const message = {
message: messageName,
payload: [`${client.ol_context.user_id}`],
}
expect(
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
).to.equal(true)
})
})
describe('link-sharing turned off', function () {
const messageName = 'project:publicAccessLevel:changed'
describe('when the new access level is set to "private"', function () {
const message = {
message: messageName,
payload: [{ newAccessLevel: 'private' }],
}
describe('when the user is an invited member', function () {
const client = {
ol_context: {
is_invited_member: true,
},
}
it('should return false', function () {
expect(
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
).to.equal(false)
})
})
describe('when the user not an invited member', function () {
const client = {
ol_context: {
is_invited_member: false,
},
}
it('should return true', function () {
expect(
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
).to.equal(true)
})
})
})
describe('when the new access level is "tokenBased"', function () {
const message = {
message: messageName,
payload: [{ newAccessLevel: 'tokenBased' }],
}
describe('when the user is an invited member', function () {
const client = {
ol_context: {
is_invited_member: true,
},
}
it('should return false', function () {
expect(
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
).to.equal(false)
})
})
describe('when the user not an invited member', function () {
const client = {
ol_context: {
is_invited_member: false,
},
}
it('should return false', function () {
expect(
this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
).to.equal(false)
})
})
})
})
})
describe('emitToRoom', function () {
beforeEach(function () {
return this.WebsocketLoadBalancer.emitToRoom(
this.room_id,
this.message,
...Array.from(this.payload)
)
})
return it('should publish the message to redis', function () {
return this.ChannelManager.publish
.calledWith(
this.WebsocketLoadBalancer.rclientPubList[0],
'editor-events',
this.room_id,
JSON.stringify({
room_id: this.room_id,
message: this.message,
payload: this.payload,
})
)
.should.equal(true)
})
})
describe('emitToAll', function () {
beforeEach(function () {
this.WebsocketLoadBalancer.emitToRoom = sinon.stub()
return this.WebsocketLoadBalancer.emitToAll(
this.message,
...Array.from(this.payload)
)
})
return it("should emit to the room 'all'", function () {
return this.WebsocketLoadBalancer.emitToRoom
.calledWith('all', this.message, ...Array.from(this.payload))
.should.equal(true)
})
})
describe('listenForEditorEvents', function () {
beforeEach(function () {
this.WebsocketLoadBalancer._processEditorEvent = sinon.stub()
return this.WebsocketLoadBalancer.listenForEditorEvents()
})
it('should subscribe to the editor-events channel', function () {
return this.WebsocketLoadBalancer.rclientSubList[0].subscribe
.calledWith('editor-events')
.should.equal(true)
})
return it('should process the events with _processEditorEvent', function () {
return this.WebsocketLoadBalancer.rclientSubList[0].on
.calledWith('message', sinon.match.func)
.should.equal(true)
})
})
return describe('_processEditorEvent', function () {
describe('with bad JSON', function () {
beforeEach(function () {
this.isRestrictedUser = false
this.SafeJsonParse.parse = sinon
.stub()
.callsArgWith(1, new Error('oops'))
return this.WebsocketLoadBalancer._processEditorEvent(
this.io,
'editor-events',
'blah'
)
})
return it('should log an error', function () {
return this.logger.error.called.should.equal(true)
})
})
describe('with a designated room', function () {
beforeEach(function () {
this.io.sockets = {
clients: sinon.stub().returns([
{
id: 'client-id-1',
emit: (this.emit1 = sinon.stub()),
ol_context: {},
},
{
id: 'client-id-2',
emit: (this.emit2 = sinon.stub()),
ol_context: {},
},
{
id: 'client-id-1',
emit: (this.emit3 = sinon.stub()),
ol_context: {},
}, // duplicate client
]),
}
const data = JSON.stringify({
room_id: this.room_id,
message: this.message,
payload: this.payload,
})
return this.WebsocketLoadBalancer._processEditorEvent(
this.io,
'editor-events',
data
)
})
return it('should send the message to all (unique) clients in the room', function () {
this.io.sockets.clients.calledWith(this.room_id).should.equal(true)
this.emit1
.calledWith(this.message, ...Array.from(this.payload))
.should.equal(true)
this.emit2
.calledWith(this.message, ...Array.from(this.payload))
.should.equal(true)
return this.emit3.called.should.equal(false)
})
}) // duplicate client should be ignored
describe('with a designated room, and restricted clients, not restricted message', function () {
beforeEach(function () {
this.io.sockets = {
clients: sinon.stub().returns([
{
id: 'client-id-1',
emit: (this.emit1 = sinon.stub()),
ol_context: {},
},
{
id: 'client-id-2',
emit: (this.emit2 = sinon.stub()),
ol_context: {},
},
{
id: 'client-id-1',
emit: (this.emit3 = sinon.stub()),
ol_context: {},
}, // duplicate client
{
id: 'client-id-4',
emit: (this.emit4 = sinon.stub()),
ol_context: { is_restricted_user: true },
},
]),
}
const data = JSON.stringify({
room_id: this.room_id,
message: this.message,
payload: this.payload,
})
return this.WebsocketLoadBalancer._processEditorEvent(
this.io,
'editor-events',
data
)
})
return it('should send the message to all (unique) clients in the room', function () {
this.io.sockets.clients.calledWith(this.room_id).should.equal(true)
this.emit1
.calledWith(this.message, ...Array.from(this.payload))
.should.equal(true)
this.emit2
.calledWith(this.message, ...Array.from(this.payload))
.should.equal(true)
this.emit3.called.should.equal(false) // duplicate client should be ignored
return this.emit4.called.should.equal(true)
})
}) // restricted client, but should be called
describe('with a designated room, and restricted clients, restricted message', function () {
beforeEach(function () {
this.io.sockets = {
clients: sinon.stub().returns([
{
id: 'client-id-1',
emit: (this.emit1 = sinon.stub()),
ol_context: {},
},
{
id: 'client-id-2',
emit: (this.emit2 = sinon.stub()),
ol_context: {},
},
{
id: 'client-id-1',
emit: (this.emit3 = sinon.stub()),
ol_context: {},
}, // duplicate client
{
id: 'client-id-4',
emit: (this.emit4 = sinon.stub()),
ol_context: { is_restricted_user: true },
},
]),
}
const data = JSON.stringify({
room_id: this.room_id,
message: (this.restrictedMessage = 'new-comment'),
payload: this.payload,
})
return this.WebsocketLoadBalancer._processEditorEvent(
this.io,
'editor-events',
data
)
})
return it('should send the message to all (unique) clients in the room, who are not restricted', function () {
this.io.sockets.clients.calledWith(this.room_id).should.equal(true)
this.emit1
.calledWith(this.restrictedMessage, ...Array.from(this.payload))
.should.equal(true)
this.emit2
.calledWith(this.restrictedMessage, ...Array.from(this.payload))
.should.equal(true)
this.emit3.called.should.equal(false) // duplicate client should be ignored
return this.emit4.called.should.equal(false)
})
}) // restricted client, should not be called
describe('when emitting to all', function () {
beforeEach(function () {
this.io.sockets = { emit: (this.emit = sinon.stub()) }
const data = JSON.stringify({
room_id: 'all',
message: this.message,
payload: this.payload,
})
return this.WebsocketLoadBalancer._processEditorEvent(
this.io,
'editor-events',
data
)
})
return it('should send the message to all clients', function () {
return this.emit
.calledWith(this.message, ...Array.from(this.payload))
.should.equal(true)
})
})
describe('when it should disconnect one of the clients', function () {
const targetUserId = 'bbb'
const message = 'userRemovedFromProject'
const payload = [`${targetUserId}`]
const clients = [
{
id: 'client-id-1',
emit: sinon.stub(),
ol_context: { user_id: 'aaa' },
disconnect: sinon.stub(),
},
{
id: 'client-id-2',
emit: sinon.stub(),
ol_context: { user_id: `${targetUserId}` },
disconnect: sinon.stub(),
},
{
id: 'client-id-3',
emit: sinon.stub(),
ol_context: { user_id: 'ccc' },
disconnect: sinon.stub(),
},
]
beforeEach(function () {
this.io.sockets = {
clients: sinon.stub().returns(clients),
}
const data = JSON.stringify({
room_id: this.room_id,
message,
payload,
})
return this.WebsocketLoadBalancer._processEditorEvent(
this.io,
'editor-events',
data
)
})
it('should disconnect the matching client, while sending message to other clients', function () {
this.io.sockets.clients.calledWith(this.room_id).should.equal(true)
const [client1, client2, client3] = clients
// disconnecting one client
client1.disconnect.called.should.equal(false)
client2.disconnect.called.should.equal(true)
client3.disconnect.called.should.equal(false)
// emitting to remaining clients
client1.emit
.calledWith(message, ...Array.from(payload))
.should.equal(true)
client2.emit.calledWith('project:access:revoked').should.equal(true) // disconnected client should get informative message
client3.emit
.calledWith(message, ...Array.from(payload))
.should.equal(true)
})
})
})
})

View File

@@ -0,0 +1,23 @@
/* eslint-disable
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
let MockClient
const sinon = require('sinon')
let idCounter = 0
module.exports = MockClient = class MockClient {
constructor() {
this.ol_context = {}
this.join = sinon.stub()
this.emit = sinon.stub()
this.disconnect = sinon.stub()
this.id = idCounter++
this.publicId = idCounter++
this.joinLeaveEpoch = 0
}
disconnect() {}
}