first commit
This commit is contained in:
599
services/real-time/test/acceptance/js/ApplyUpdateTests.js
Normal file
599
services/real-time/test/acceptance/js/ApplyUpdateTests.js
Normal file
@@ -0,0 +1,599 @@
|
||||
/* 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
|
||||
* DS201: Simplify complex destructure assignments
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const async = require('async')
|
||||
const { expect } = require('chai')
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const rclient = redis.createClient(settings.redis.documentupdater)
|
||||
|
||||
const redisSettings = settings.redis
|
||||
|
||||
const PENDING_UPDATES_LIST_KEYS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => {
|
||||
let key = 'pending-updates-list'
|
||||
if (n !== 0) {
|
||||
key += `-${n}`
|
||||
}
|
||||
return key
|
||||
})
|
||||
|
||||
function getPendingUpdatesList(cb) {
|
||||
Promise.all(PENDING_UPDATES_LIST_KEYS.map(key => rclient.lrange(key, 0, -1)))
|
||||
.then(results => {
|
||||
cb(
|
||||
null,
|
||||
results.reduce((acc, more) => {
|
||||
if (more.length) {
|
||||
acc.push(...more)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
)
|
||||
})
|
||||
.catch(cb)
|
||||
}
|
||||
|
||||
function clearPendingUpdatesList(cb) {
|
||||
Promise.all(PENDING_UPDATES_LIST_KEYS.map(key => rclient.del(key)))
|
||||
.then(() => cb(null))
|
||||
.catch(cb)
|
||||
}
|
||||
|
||||
describe('applyOtUpdate', function () {
|
||||
before(function () {
|
||||
return (this.update = {
|
||||
op: [{ i: 'foo', p: 42 }],
|
||||
})
|
||||
})
|
||||
describe('when authorized', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readAndWrite',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'applyOtUpdate',
|
||||
this.doc_id,
|
||||
this.update,
|
||||
cb
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the doc into the pending updates list', function (done) {
|
||||
getPendingUpdatesList((error, ...rest) => {
|
||||
if (error) return done(error)
|
||||
const [docId] = Array.from(rest[0])
|
||||
docId.should.equal(`${this.project_id}:${this.doc_id}`)
|
||||
return done()
|
||||
})
|
||||
return null
|
||||
})
|
||||
|
||||
it('should push the update into redis', function (done) {
|
||||
rclient.lrange(
|
||||
redisSettings.documentupdater.key_schema.pendingUpdates({
|
||||
doc_id: this.doc_id,
|
||||
}),
|
||||
0,
|
||||
-1,
|
||||
(error, ...rest) => {
|
||||
if (error) return done(error)
|
||||
let [update] = Array.from(rest[0])
|
||||
update = JSON.parse(update)
|
||||
update.op.should.deep.equal(this.update.op)
|
||||
update.meta.should.include({
|
||||
source: this.client.publicId,
|
||||
user_id: this.user_id,
|
||||
})
|
||||
return done()
|
||||
}
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
return after(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => clearPendingUpdatesList(cb),
|
||||
cb =>
|
||||
rclient.del(
|
||||
'DocsWithPendingUpdates',
|
||||
`${this.project_id}:${this.doc_id}`,
|
||||
cb
|
||||
),
|
||||
cb =>
|
||||
rclient.del(
|
||||
redisSettings.documentupdater.key_schema.pendingUpdates(
|
||||
this.doc_id
|
||||
),
|
||||
cb
|
||||
),
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when authorized with a huge edit update', function () {
|
||||
before(function (done) {
|
||||
this.update = {
|
||||
op: {
|
||||
p: 12,
|
||||
t: 'update is too large'.repeat(1024 * 400), // >7MB
|
||||
},
|
||||
}
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readAndWrite',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
return this.client.on('otUpdateError', otUpdateError => {
|
||||
this.otUpdateError = otUpdateError
|
||||
})
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'applyOtUpdate',
|
||||
this.doc_id,
|
||||
this.update,
|
||||
error => {
|
||||
this.error = error
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should not return an error', function () {
|
||||
return expect(this.error).to.not.exist
|
||||
})
|
||||
|
||||
it('should send an otUpdateError to the client', function (done) {
|
||||
return setTimeout(() => {
|
||||
expect(this.otUpdateError).to.exist
|
||||
return done()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
it('should disconnect the client', function (done) {
|
||||
return setTimeout(() => {
|
||||
this.client.socket.connected.should.equal(false)
|
||||
return done()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
return it('should not put the update in redis', function (done) {
|
||||
rclient.llen(
|
||||
redisSettings.documentupdater.key_schema.pendingUpdates({
|
||||
doc_id: this.doc_id,
|
||||
}),
|
||||
(error, len) => {
|
||||
if (error) return done(error)
|
||||
len.should.equal(0)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
describe('when authorized to read-only with an edit update', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readOnly',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'applyOtUpdate',
|
||||
this.doc_id,
|
||||
this.update,
|
||||
error => {
|
||||
this.error = error
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should return an error', function () {
|
||||
return expect(this.error).to.exist
|
||||
})
|
||||
|
||||
it('should disconnect the client', function (done) {
|
||||
return setTimeout(() => {
|
||||
this.client.socket.connected.should.equal(false)
|
||||
return done()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
return it('should not put the update in redis', function (done) {
|
||||
rclient.llen(
|
||||
redisSettings.documentupdater.key_schema.pendingUpdates({
|
||||
doc_id: this.doc_id,
|
||||
}),
|
||||
(error, len) => {
|
||||
if (error) return done(error)
|
||||
len.should.equal(0)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
describe('when authorized to read-only with a comment update', function () {
|
||||
before(function (done) {
|
||||
this.comment_update = {
|
||||
op: [{ c: 'foo', p: 42 }],
|
||||
}
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readOnly',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'applyOtUpdate',
|
||||
this.doc_id,
|
||||
this.comment_update,
|
||||
cb
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should push the doc into the pending updates list', function (done) {
|
||||
getPendingUpdatesList((error, ...rest) => {
|
||||
if (error) return done(error)
|
||||
const [docId] = Array.from(rest[0])
|
||||
docId.should.equal(`${this.project_id}:${this.doc_id}`)
|
||||
return done()
|
||||
})
|
||||
return null
|
||||
})
|
||||
|
||||
it('should push the update into redis', function (done) {
|
||||
rclient.lrange(
|
||||
redisSettings.documentupdater.key_schema.pendingUpdates({
|
||||
doc_id: this.doc_id,
|
||||
}),
|
||||
0,
|
||||
-1,
|
||||
(error, ...rest) => {
|
||||
if (error) return done(error)
|
||||
let [update] = Array.from(rest[0])
|
||||
update = JSON.parse(update)
|
||||
update.op.should.deep.equal(this.comment_update.op)
|
||||
update.meta.should.include({
|
||||
source: this.client.publicId,
|
||||
user_id: this.user_id,
|
||||
})
|
||||
return done()
|
||||
}
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
return after(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => clearPendingUpdatesList(cb),
|
||||
cb =>
|
||||
rclient.del(
|
||||
'DocsWithPendingUpdates',
|
||||
`${this.project_id}:${this.doc_id}`,
|
||||
cb
|
||||
),
|
||||
cb =>
|
||||
rclient.del(
|
||||
redisSettings.documentupdater.key_schema.pendingUpdates({
|
||||
doc_id: this.doc_id,
|
||||
}),
|
||||
cb
|
||||
),
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when authorized with an edit update to an invalid doc', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readOnly',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'applyOtUpdate',
|
||||
'invalid-doc-id',
|
||||
this.update,
|
||||
error => {
|
||||
this.error = error
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should return an error', function () {
|
||||
return expect(this.error).to.exist
|
||||
})
|
||||
|
||||
it('should disconnect the client', function (done) {
|
||||
return setTimeout(() => {
|
||||
this.client.socket.connected.should.equal(false)
|
||||
return done()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
return it('should not put the update in redis', function (done) {
|
||||
rclient.llen(
|
||||
redisSettings.documentupdater.key_schema.pendingUpdates({
|
||||
doc_id: this.doc_id,
|
||||
}),
|
||||
(error, len) => {
|
||||
if (error) return done(error)
|
||||
len.should.equal(0)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
describe('when authorized with an invalid edit update', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readAndWrite',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'applyOtUpdate',
|
||||
this.doc_id,
|
||||
'invalid-update',
|
||||
error => {
|
||||
this.error = error
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should return an error', function () {
|
||||
return expect(this.error).to.exist
|
||||
})
|
||||
|
||||
it('should disconnect the client', function (done) {
|
||||
return setTimeout(() => {
|
||||
this.client.socket.connected.should.equal(false)
|
||||
return done()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
return it('should not put the update in redis', function (done) {
|
||||
rclient.llen(
|
||||
redisSettings.documentupdater.key_schema.pendingUpdates({
|
||||
doc_id: this.doc_id,
|
||||
}),
|
||||
(error, len) => {
|
||||
if (error) return done(error)
|
||||
len.should.equal(0)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
return null
|
||||
})
|
||||
})
|
||||
})
|
218
services/real-time/test/acceptance/js/ClientTrackingTests.js
Normal file
218
services/real-time/test/acceptance/js/ClientTrackingTests.js
Normal file
@@ -0,0 +1,218 @@
|
||||
/* eslint-disable
|
||||
no-unused-vars,
|
||||
*/
|
||||
// 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
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockWebServer = require('./helpers/MockWebServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const async = require('async')
|
||||
|
||||
describe('clientTracking', function () {
|
||||
describe('when a client updates its cursor location', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: { name: 'Test Project' },
|
||||
},
|
||||
(error, { user_id: userId, project_id: projectId }) => {
|
||||
if (error) return done(error)
|
||||
this.user_id = userId
|
||||
this.project_id = projectId
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientB = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.clientA.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.updates = []
|
||||
this.clientB.on('clientTracking.clientUpdated', data => {
|
||||
return this.updates.push(data)
|
||||
})
|
||||
|
||||
return this.clientA.emit(
|
||||
'clientTracking.updatePosition',
|
||||
{
|
||||
row: (this.row = 42),
|
||||
column: (this.column = 36),
|
||||
doc_id: this.doc_id,
|
||||
},
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return setTimeout(cb, 300)
|
||||
}
|
||||
)
|
||||
}, // Give the message a chance to reach client B.
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should tell other clients about the update', function () {
|
||||
return this.updates.should.deep.equal([
|
||||
{
|
||||
row: this.row,
|
||||
column: this.column,
|
||||
doc_id: this.doc_id,
|
||||
id: this.clientA.publicId,
|
||||
user_id: this.user_id,
|
||||
name: 'Joe Bloggs',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
return it('should record the update in getConnectedUsers', function (done) {
|
||||
return this.clientB.emit(
|
||||
'clientTracking.getConnectedUsers',
|
||||
(error, users) => {
|
||||
if (error) return done(error)
|
||||
for (const user of Array.from(users)) {
|
||||
if (user.client_id === this.clientA.publicId) {
|
||||
expect(user.cursorData).to.deep.equal({
|
||||
row: this.row,
|
||||
column: this.column,
|
||||
doc_id: this.doc_id,
|
||||
})
|
||||
return done()
|
||||
}
|
||||
}
|
||||
throw new Error('user was never found')
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when an anonymous client updates its cursor location', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: { name: 'Test Project' },
|
||||
publicAccess: 'readAndWrite',
|
||||
},
|
||||
(
|
||||
error,
|
||||
{ user_id: userId, project_id: projectId, anonymousAccessToken }
|
||||
) => {
|
||||
if (error) return done(error)
|
||||
this.user_id = userId
|
||||
this.project_id = projectId
|
||||
this.anonymousAccessToken = anonymousAccessToken
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
RealTimeClient.setAnonSession(
|
||||
this.project_id,
|
||||
this.anonymousAccessToken,
|
||||
cb
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.anonymous = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.anonymous.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.updates = []
|
||||
this.clientA.on('clientTracking.clientUpdated', data => {
|
||||
return this.updates.push(data)
|
||||
})
|
||||
|
||||
return this.anonymous.emit(
|
||||
'clientTracking.updatePosition',
|
||||
{
|
||||
row: (this.row = 42),
|
||||
column: (this.column = 36),
|
||||
doc_id: this.doc_id,
|
||||
},
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return setTimeout(cb, 300)
|
||||
}
|
||||
)
|
||||
}, // Give the message a chance to reach client B.
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should tell other clients about the update', function () {
|
||||
return this.updates.should.deep.equal([
|
||||
{
|
||||
row: this.row,
|
||||
column: this.column,
|
||||
doc_id: this.doc_id,
|
||||
id: this.anonymous.publicId,
|
||||
user_id: 'anonymous-user',
|
||||
name: '',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
108
services/real-time/test/acceptance/js/DrainManagerTests.js
Normal file
108
services/real-time/test/acceptance/js/DrainManagerTests.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// 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 RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const { expect } = require('chai')
|
||||
|
||||
const async = require('async')
|
||||
const request = require('request')
|
||||
|
||||
const drain = function (rate, callback) {
|
||||
request.post(
|
||||
{
|
||||
url: `http://127.0.0.1:3026/drain?rate=${rate}`,
|
||||
},
|
||||
(error, response, data) => callback(error, data)
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
describe('DrainManagerTests', function () {
|
||||
before(function (done) {
|
||||
FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return done()
|
||||
}
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
before(function (done) {
|
||||
// cleanup to speedup reconnecting
|
||||
this.timeout(10000)
|
||||
return RealTimeClient.disconnectAllClients(done)
|
||||
})
|
||||
|
||||
// trigger and check cleanup
|
||||
it('should have disconnected all previous clients', function (done) {
|
||||
return RealTimeClient.getConnectedClients((error, data) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
expect(data.length).to.equal(0)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
return describe('with two clients in the project', function () {
|
||||
beforeEach(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
this.clientA = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientB = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return describe('starting to drain', function () {
|
||||
beforeEach(function (done) {
|
||||
return async.parallel(
|
||||
[
|
||||
cb => {
|
||||
return this.clientA.on('reconnectGracefully', cb)
|
||||
},
|
||||
cb => {
|
||||
return this.clientB.on('reconnectGracefully', cb)
|
||||
},
|
||||
|
||||
cb => drain(2, cb),
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(function (done) {
|
||||
return drain(0, done)
|
||||
}) // reset drain
|
||||
|
||||
it('should not timeout', function () {
|
||||
return expect(true).to.equal(true)
|
||||
})
|
||||
|
||||
return it('should not have disconnected', function () {
|
||||
expect(this.clientA.socket.connected).to.equal(true)
|
||||
return expect(this.clientB.socket.connected).to.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
265
services/real-time/test/acceptance/js/EarlyDisconnect.js
Normal file
265
services/real-time/test/acceptance/js/EarlyDisconnect.js
Normal file
@@ -0,0 +1,265 @@
|
||||
/* 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 async = require('async')
|
||||
const { expect } = require('chai')
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
|
||||
const MockWebServer = require('./helpers/MockWebServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const rclient = redis.createClient(settings.redis.pubsub)
|
||||
const rclientRT = redis.createClient(settings.redis.realtime)
|
||||
const KeysRT = settings.redis.realtime.key_schema
|
||||
|
||||
describe('EarlyDisconnect', function () {
|
||||
before(function (done) {
|
||||
return MockDocUpdaterServer.run(done)
|
||||
})
|
||||
|
||||
describe('when the client disconnects before joinProject completes', function () {
|
||||
before(function () {
|
||||
// slow down web-api requests to force the race condition
|
||||
this.actualWebAPIjoinProject = MockWebServer.joinProject
|
||||
MockWebServer.joinProject = (...args) =>
|
||||
setTimeout(() => this.actualWebAPIjoinProject(...args), 300)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
return (MockWebServer.joinProject = this.actualWebAPIjoinProject)
|
||||
})
|
||||
|
||||
beforeEach(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA = RealTimeClient.connect(this.project_id, cb)
|
||||
// disconnect after the handshake and before joinProject completes
|
||||
setTimeout(() => this.clientA.disconnect(), 100)
|
||||
this.clientA.on('disconnect', () => cb())
|
||||
},
|
||||
|
||||
cb => {
|
||||
// wait for joinDoc and subscribe
|
||||
return setTimeout(cb, 500)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
// we can force the race condition, there is no need to repeat too often
|
||||
return Array.from(Array.from({ length: 5 }).map((_, i) => i + 1)).map(
|
||||
attempt =>
|
||||
it(`should not subscribe to the pub/sub channel anymore (race ${attempt})`, function (done) {
|
||||
rclient.pubsub('CHANNELS', (err, resp) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
expect(resp).to.not.include(`editor-events:${this.project_id}`)
|
||||
return done()
|
||||
})
|
||||
return null
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('when the client disconnects before joinDoc completes', function () {
|
||||
beforeEach(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA = RealTimeClient.connect(
|
||||
this.project_id,
|
||||
(error, project, privilegeLevel, protocolVersion) => {
|
||||
this.project = project
|
||||
this.privilegeLevel = privilegeLevel
|
||||
this.protocolVersion = protocolVersion
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA.emit('joinDoc', this.doc_id, () => {})
|
||||
// disconnect before joinDoc completes
|
||||
this.clientA.on('disconnect', () => cb())
|
||||
return this.clientA.disconnect()
|
||||
},
|
||||
|
||||
cb => {
|
||||
// wait for subscribe and unsubscribe
|
||||
return setTimeout(cb, 100)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
// we can not force the race condition, so we have to try many times
|
||||
return Array.from(Array.from({ length: 20 }).map((_, i) => i + 1)).map(
|
||||
attempt =>
|
||||
it(`should not subscribe to the pub/sub channels anymore (race ${attempt})`, function (done) {
|
||||
rclient.pubsub('CHANNELS', (err, resp) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
expect(resp).to.not.include(`editor-events:${this.project_id}`)
|
||||
|
||||
return rclient.pubsub('CHANNELS', (err, resp) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
expect(resp).to.not.include(`applied-ops:${this.doc_id}`)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
return null
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
return describe('when the client disconnects before clientTracking.updatePosition starts', function () {
|
||||
beforeEach(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA = RealTimeClient.connect(
|
||||
this.project_id,
|
||||
(error, project, privilegeLevel, protocolVersion) => {
|
||||
this.project = project
|
||||
this.privilegeLevel = privilegeLevel
|
||||
this.protocolVersion = protocolVersion
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.clientA.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA.emit(
|
||||
'clientTracking.updatePosition',
|
||||
{
|
||||
row: 42,
|
||||
column: 36,
|
||||
doc_id: this.doc_id,
|
||||
},
|
||||
() => {}
|
||||
)
|
||||
// disconnect before updateClientPosition completes
|
||||
this.clientA.on('disconnect', () => cb())
|
||||
return this.clientA.disconnect()
|
||||
},
|
||||
|
||||
cb => {
|
||||
// wait for updateClientPosition
|
||||
return setTimeout(cb, 100)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
// we can not force the race condition, so we have to try many times
|
||||
return Array.from(Array.from({ length: 20 }).map((_, i) => i + 1)).map(
|
||||
attempt =>
|
||||
it(`should not show the client as connected (race ${attempt})`, function (done) {
|
||||
rclientRT.smembers(
|
||||
KeysRT.clientsInProject({ project_id: this.project_id }),
|
||||
(err, results) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
expect(results).to.deep.equal([])
|
||||
return done()
|
||||
}
|
||||
)
|
||||
return null
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
105
services/real-time/test/acceptance/js/HttpControllerTests.js
Normal file
105
services/real-time/test/acceptance/js/HttpControllerTests.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// 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 async = require('async')
|
||||
const { expect } = require('chai')
|
||||
const request = require('request').defaults({
|
||||
baseUrl: 'http://127.0.0.1:3026',
|
||||
})
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
describe('HttpControllerTests', function () {
|
||||
describe('without a user', function () {
|
||||
return it('should return 404 for the client view', function (done) {
|
||||
const clientId = 'not-existing'
|
||||
return request.get(
|
||||
{
|
||||
url: `/clients/${clientId}`,
|
||||
json: true,
|
||||
},
|
||||
(error, response, data) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
expect(response.statusCode).to.equal(404)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('with a user and after joining a project', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
},
|
||||
(error, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{},
|
||||
(error, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should send a client view', function (done) {
|
||||
return request.get(
|
||||
{
|
||||
url: `/clients/${this.client.socket.sessionid}`,
|
||||
json: true,
|
||||
},
|
||||
(error, response, data) => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
expect(response.statusCode).to.equal(200)
|
||||
expect(data.connected_time).to.exist
|
||||
delete data.connected_time
|
||||
// .email is not set in the session
|
||||
delete data.email
|
||||
expect(data).to.deep.equal({
|
||||
client_id: this.client.socket.sessionid,
|
||||
first_name: 'Joe',
|
||||
last_name: 'Bloggs',
|
||||
project_id: this.project_id,
|
||||
user_id: this.user_id,
|
||||
rooms: [this.project_id, this.doc_id],
|
||||
})
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
589
services/real-time/test/acceptance/js/JoinDocTests.js
Normal file
589
services/real-time/test/acceptance/js/JoinDocTests.js
Normal file
@@ -0,0 +1,589 @@
|
||||
/* 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 { expect } = require('chai')
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const async = require('async')
|
||||
|
||||
describe('joinDoc', function () {
|
||||
before(function () {
|
||||
this.lines = ['test', 'doc', 'lines']
|
||||
this.version = 42
|
||||
this.ops = ['mock', 'doc', 'ops']
|
||||
return (this.ranges = { mock: 'ranges' })
|
||||
})
|
||||
|
||||
describe('when authorised readAndWrite', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readAndWrite',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
ops: this.ops,
|
||||
ranges: this.ranges,
|
||||
},
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'joinDoc',
|
||||
this.doc_id,
|
||||
(error, ...rest) => {
|
||||
;[...this.returnedArgs] = Array.from(rest)
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should get the doc from the doc updater', function () {
|
||||
return MockDocUpdaterServer.getDocument
|
||||
.calledWith(this.project_id, this.doc_id, -1)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return the doc lines, version, ranges and ops', function () {
|
||||
return this.returnedArgs.should.deep.equal([
|
||||
this.lines,
|
||||
this.version,
|
||||
this.ops,
|
||||
this.ranges,
|
||||
])
|
||||
})
|
||||
|
||||
return it('should have joined the doc room', function (done) {
|
||||
return RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error) return done(error)
|
||||
expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when authorised readOnly', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readOnly',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
ops: this.ops,
|
||||
ranges: this.ranges,
|
||||
},
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'joinDoc',
|
||||
this.doc_id,
|
||||
(error, ...rest) => {
|
||||
;[...this.returnedArgs] = Array.from(rest)
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should get the doc from the doc updater', function () {
|
||||
return MockDocUpdaterServer.getDocument
|
||||
.calledWith(this.project_id, this.doc_id, -1)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return the doc lines, version, ranges and ops', function () {
|
||||
return this.returnedArgs.should.deep.equal([
|
||||
this.lines,
|
||||
this.version,
|
||||
this.ops,
|
||||
this.ranges,
|
||||
])
|
||||
})
|
||||
|
||||
return it('should have joined the doc room', function (done) {
|
||||
return RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error) return done(error)
|
||||
expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when authorised as owner', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
ops: this.ops,
|
||||
ranges: this.ranges,
|
||||
},
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'joinDoc',
|
||||
this.doc_id,
|
||||
(error, ...rest) => {
|
||||
;[...this.returnedArgs] = Array.from(rest)
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should get the doc from the doc updater', function () {
|
||||
return MockDocUpdaterServer.getDocument
|
||||
.calledWith(this.project_id, this.doc_id, -1)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return the doc lines, version, ranges and ops', function () {
|
||||
return this.returnedArgs.should.deep.equal([
|
||||
this.lines,
|
||||
this.version,
|
||||
this.ops,
|
||||
this.ranges,
|
||||
])
|
||||
})
|
||||
|
||||
return it('should have joined the doc room', function (done) {
|
||||
return RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error) return done(error)
|
||||
expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// It is impossible to write an acceptance test to test joining an unauthorized
|
||||
// project, since joinProject already catches that. If you can join a project,
|
||||
// then you can join a doc in that project.
|
||||
|
||||
describe('for an invalid doc', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
ops: this.ops,
|
||||
ranges: this.ranges,
|
||||
},
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'joinDoc',
|
||||
'invalid-doc-id',
|
||||
(error, ...rest) => {
|
||||
this.error = error
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should not get the doc from the doc updater', function () {
|
||||
return MockDocUpdaterServer.getDocument
|
||||
.calledWith(this.project_id, 'invalid-doc-id')
|
||||
.should.equal(false)
|
||||
})
|
||||
|
||||
it('should return an invalid id error', function () {
|
||||
this.error.message.should.equal('invalid id')
|
||||
})
|
||||
|
||||
return it('should not have joined the doc room', function (done) {
|
||||
return RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error) return done(error)
|
||||
expect(Array.from(client.rooms).includes('invalid-doc-id')).to.equal(
|
||||
false
|
||||
)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a fromVersion', function () {
|
||||
before(function (done) {
|
||||
this.fromVersion = 36
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readAndWrite',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
ops: this.ops,
|
||||
ranges: this.ranges,
|
||||
},
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'joinDoc',
|
||||
this.doc_id,
|
||||
this.fromVersion,
|
||||
(error, ...rest) => {
|
||||
;[...this.returnedArgs] = Array.from(rest)
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should get the doc from the doc updater with the fromVersion', function () {
|
||||
return MockDocUpdaterServer.getDocument
|
||||
.calledWith(this.project_id, this.doc_id, this.fromVersion)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return the doc lines, version, ranges and ops', function () {
|
||||
return this.returnedArgs.should.deep.equal([
|
||||
this.lines,
|
||||
this.version,
|
||||
this.ops,
|
||||
this.ranges,
|
||||
])
|
||||
})
|
||||
|
||||
return it('should have joined the doc room', function (done) {
|
||||
return RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error) return done(error)
|
||||
expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with options', function () {
|
||||
before(function (done) {
|
||||
this.options = { encodeRanges: true }
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readAndWrite',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
ops: this.ops,
|
||||
ranges: this.ranges,
|
||||
},
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'joinDoc',
|
||||
this.doc_id,
|
||||
this.options,
|
||||
(error, ...rest) => {
|
||||
;[...this.returnedArgs] = Array.from(rest)
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should get the doc from the doc updater with the default fromVersion', function () {
|
||||
return MockDocUpdaterServer.getDocument
|
||||
.calledWith(this.project_id, this.doc_id, -1)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return the doc lines, version, ranges and ops', function () {
|
||||
return this.returnedArgs.should.deep.equal([
|
||||
this.lines,
|
||||
this.version,
|
||||
this.ops,
|
||||
this.ranges,
|
||||
])
|
||||
})
|
||||
|
||||
return it('should have joined the doc room', function (done) {
|
||||
return RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error) return done(error)
|
||||
expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('with fromVersion and options', function () {
|
||||
before(function (done) {
|
||||
this.fromVersion = 36
|
||||
this.options = { encodeRanges: true }
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readAndWrite',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{
|
||||
lines: this.lines,
|
||||
version: this.version,
|
||||
ops: this.ops,
|
||||
ranges: this.ranges,
|
||||
},
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'joinDoc',
|
||||
this.doc_id,
|
||||
this.fromVersion,
|
||||
this.options,
|
||||
(error, ...rest) => {
|
||||
;[...this.returnedArgs] = Array.from(rest)
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should get the doc from the doc updater with the fromVersion', function () {
|
||||
return MockDocUpdaterServer.getDocument
|
||||
.calledWith(this.project_id, this.doc_id, this.fromVersion)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return the doc lines, version, ranges and ops', function () {
|
||||
return this.returnedArgs.should.deep.equal([
|
||||
this.lines,
|
||||
this.version,
|
||||
this.ops,
|
||||
this.ranges,
|
||||
])
|
||||
})
|
||||
|
||||
return it('should have joined the doc room', function (done) {
|
||||
return RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error) return done(error)
|
||||
expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
861
services/real-time/test/acceptance/js/JoinProjectTests.js
Normal file
861
services/real-time/test/acceptance/js/JoinProjectTests.js
Normal file
@@ -0,0 +1,861 @@
|
||||
// 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 { expect } = require('chai')
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockWebServer = require('./helpers/MockWebServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const async = require('async')
|
||||
|
||||
describe('joinProject', function () {
|
||||
describe('when authorized', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(
|
||||
this.project_id,
|
||||
(error, project, privilegeLevel, protocolVersion) => {
|
||||
this.project = project
|
||||
this.privilegeLevel = privilegeLevel
|
||||
this.protocolVersion = protocolVersion
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should get the project from web', function () {
|
||||
return MockWebServer.joinProject
|
||||
.calledWith(this.project_id, this.user_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return the project', function () {
|
||||
return this.project.should.deep.equal({
|
||||
name: 'Test Project',
|
||||
owner: { _id: this.user_id },
|
||||
})
|
||||
})
|
||||
|
||||
it('should return the privilege level', function () {
|
||||
return this.privilegeLevel.should.equal('owner')
|
||||
})
|
||||
|
||||
it('should return the protocolVersion', function () {
|
||||
return this.protocolVersion.should.equal(2)
|
||||
})
|
||||
|
||||
it('should have joined the project room', function (done) {
|
||||
return RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error) return done(error)
|
||||
expect(Array.from(client.rooms).includes(this.project_id)).to.equal(
|
||||
true
|
||||
)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return it('should have marked the user as connected', function (done) {
|
||||
return this.client.emit(
|
||||
'clientTracking.getConnectedUsers',
|
||||
(error, users) => {
|
||||
if (error) return done(error)
|
||||
let connected = false
|
||||
for (const user of Array.from(users)) {
|
||||
if (
|
||||
user.client_id === this.client.publicId &&
|
||||
user.user_id === this.user_id
|
||||
) {
|
||||
connected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(connected).to.equal(true)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when authorized with token', function () {
|
||||
before(function (done) {
|
||||
async.series(
|
||||
[
|
||||
cb => {
|
||||
FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
publicAccess: 'readOnly',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(
|
||||
e,
|
||||
{
|
||||
user_id: ownerId,
|
||||
project_id: projectId,
|
||||
anonymousAccessToken,
|
||||
}
|
||||
) => {
|
||||
this.ownerId = ownerId
|
||||
this.project_id = projectId
|
||||
this.anonymousAccessToken = anonymousAccessToken
|
||||
cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
RealTimeClient.setAnonSession(
|
||||
this.project_id,
|
||||
this.anonymousAccessToken,
|
||||
cb
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(
|
||||
this.project_id,
|
||||
(error, project, privilegeLevel, protocolVersion) => {
|
||||
this.project = project
|
||||
this.privilegeLevel = privilegeLevel
|
||||
this.protocolVersion = protocolVersion
|
||||
cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should get the project from web', function () {
|
||||
MockWebServer.joinProject
|
||||
.calledWith(
|
||||
this.project_id,
|
||||
'anonymous-user',
|
||||
this.anonymousAccessToken
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return the project', function () {
|
||||
this.project.should.deep.equal({
|
||||
name: 'Test Project',
|
||||
owner: { _id: this.ownerId },
|
||||
})
|
||||
})
|
||||
|
||||
it('should return the privilege level', function () {
|
||||
this.privilegeLevel.should.equal('readOnly')
|
||||
})
|
||||
|
||||
it('should return the protocolVersion', function () {
|
||||
this.protocolVersion.should.equal(2)
|
||||
})
|
||||
|
||||
it('should have joined the project room', function (done) {
|
||||
RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error) return done(error)
|
||||
expect(Array.from(client.rooms).includes(this.project_id)).to.equal(
|
||||
true
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should have marked the user as connected', function (done) {
|
||||
this.client.emit('clientTracking.getConnectedUsers', (error, users) => {
|
||||
if (error) return done(error)
|
||||
let connected = false
|
||||
for (const user of Array.from(users)) {
|
||||
if (user.client_id === this.client.publicId) {
|
||||
connected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(connected).to.equal(true)
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when not authorized', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: null,
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(
|
||||
this.project_id,
|
||||
(error, project, privilegeLevel, protocolVersion) => {
|
||||
this.error = error
|
||||
this.project = project
|
||||
this.privilegeLevel = privilegeLevel
|
||||
this.protocolVersion = protocolVersion
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should return an error', function () {
|
||||
return this.error.message.should.equal('not authorized')
|
||||
})
|
||||
|
||||
return it('should not have joined the project room', function (done) {
|
||||
return RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
error => {
|
||||
expect(error.message).to.equal('not found')
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when not authorized and web replies with a 403', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
project_id: '403403403403403403403403', // forbidden
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, error => {
|
||||
this.error = error
|
||||
cb()
|
||||
})
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should return an error', function () {
|
||||
this.error.message.should.equal('not authorized')
|
||||
})
|
||||
|
||||
it('should not have joined the project room', function (done) {
|
||||
RealTimeClient.getConnectedClient(this.client.socket.sessionid, error => {
|
||||
expect(error.message).to.equal('not found')
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when deleted and web replies with a 404', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
project_id: '404404404404404404404404', // not-found
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, error => {
|
||||
this.error = error
|
||||
cb()
|
||||
})
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should return an error', function () {
|
||||
this.error.code.should.equal('ProjectNotFound')
|
||||
})
|
||||
|
||||
it('should not have joined the project room', function (done) {
|
||||
RealTimeClient.getConnectedClient(this.client.socket.sessionid, error => {
|
||||
expect(error.message).to.equal('not found')
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when invalid', function () {
|
||||
before(function (done) {
|
||||
MockWebServer.joinProject.resetHistory()
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect('invalid-id', error => {
|
||||
this.error = error
|
||||
return cb()
|
||||
})
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should return an invalid id error', function () {
|
||||
this.error.message.should.equal(
|
||||
'missing/bad ?projectId=... query flag on handshake'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not call to web', function () {
|
||||
MockWebServer.joinProject.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when over rate limit', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(
|
||||
'429429429429429429429429', // rate-limited
|
||||
error => {
|
||||
this.error = error
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return a TooManyRequests error code', function () {
|
||||
this.error.message.should.equal('rate-limit hit when joining project')
|
||||
return this.error.code.should.equal('TooManyRequests')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when automatically joining the project', function () {
|
||||
describe('when authorized', function () {
|
||||
before(function (done) {
|
||||
async.series(
|
||||
[
|
||||
cb => {
|
||||
FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(
|
||||
this.project_id,
|
||||
(err, project, permissionsLevel, protocolVersion) => {
|
||||
this.project = project
|
||||
this.permissionsLevel = permissionsLevel
|
||||
this.protocolVersion = protocolVersion
|
||||
cb(err)
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should get the project from web', function () {
|
||||
MockWebServer.joinProject
|
||||
.calledWith(this.project_id, this.user_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return the project', function () {
|
||||
this.project.should.deep.equal({
|
||||
name: 'Test Project',
|
||||
owner: { _id: this.user_id },
|
||||
})
|
||||
})
|
||||
|
||||
it('should return the privilege level', function () {
|
||||
this.permissionsLevel.should.equal('owner')
|
||||
})
|
||||
|
||||
it('should return the protocolVersion', function () {
|
||||
this.protocolVersion.should.equal(2)
|
||||
})
|
||||
|
||||
it('should have joined the project room', function (done) {
|
||||
RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error) return done(error)
|
||||
expect(Array.from(client.rooms).includes(this.project_id)).to.equal(
|
||||
true
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should have marked the user as connected', function (done) {
|
||||
this.client.emit('clientTracking.getConnectedUsers', (error, users) => {
|
||||
if (error) return done(error)
|
||||
let connected = false
|
||||
for (const user of Array.from(users)) {
|
||||
if (
|
||||
user.client_id === this.client.publicId &&
|
||||
user.user_id === this.user_id
|
||||
) {
|
||||
connected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(connected).to.equal(true)
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when authorized with token', function () {
|
||||
before(function (done) {
|
||||
async.series(
|
||||
[
|
||||
cb => {
|
||||
FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
publicAccess: 'readOnly',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(
|
||||
e,
|
||||
{
|
||||
user_id: ownerId,
|
||||
project_id: projectId,
|
||||
anonymousAccessToken,
|
||||
}
|
||||
) => {
|
||||
this.ownerId = ownerId
|
||||
this.project_id = projectId
|
||||
this.anonymousAccessToken = anonymousAccessToken
|
||||
cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
RealTimeClient.setAnonSession(
|
||||
this.project_id,
|
||||
this.anonymousAccessToken,
|
||||
cb
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(
|
||||
this.project_id,
|
||||
(err, project, permissionsLevel, protocolVersion) => {
|
||||
this.project = project
|
||||
this.permissionsLevel = permissionsLevel
|
||||
this.protocolVersion = protocolVersion
|
||||
cb(err)
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should get the project from web', function () {
|
||||
MockWebServer.joinProject
|
||||
.calledWith(
|
||||
this.project_id,
|
||||
'anonymous-user',
|
||||
this.anonymousAccessToken
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should return the project', function () {
|
||||
this.project.should.deep.equal({
|
||||
name: 'Test Project',
|
||||
owner: { _id: this.ownerId },
|
||||
})
|
||||
})
|
||||
|
||||
it('should return the privilege level', function () {
|
||||
this.permissionsLevel.should.equal('readOnly')
|
||||
})
|
||||
|
||||
it('should return the protocolVersion', function () {
|
||||
this.protocolVersion.should.equal(2)
|
||||
})
|
||||
|
||||
it('should have joined the project room', function (done) {
|
||||
RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error) return done(error)
|
||||
expect(Array.from(client.rooms).includes(this.project_id)).to.equal(
|
||||
true
|
||||
)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should have marked the user as connected', function (done) {
|
||||
this.client.emit('clientTracking.getConnectedUsers', (error, users) => {
|
||||
if (error) return done(error)
|
||||
let connected = false
|
||||
for (const user of Array.from(users)) {
|
||||
if (user.client_id === this.client.publicId) {
|
||||
connected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(connected).to.equal(true)
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when not authorized', function () {
|
||||
let joinProjectResponseReceived = false
|
||||
before(function (done) {
|
||||
async.series(
|
||||
[
|
||||
cb => {
|
||||
FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: null,
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, err => {
|
||||
this.error = err
|
||||
cb()
|
||||
})
|
||||
this.client.on('joinProjectResponse', () => {
|
||||
joinProjectResponseReceived = true
|
||||
cb()
|
||||
})
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should not emit joinProjectResponse', function () {
|
||||
expect(joinProjectResponseReceived).to.equal(false)
|
||||
})
|
||||
|
||||
it('should have disconnected the client', function () {
|
||||
expect(this.client.socket.connected).to.equal(false)
|
||||
})
|
||||
|
||||
it('should return an error', function () {
|
||||
this.error.message.should.equal('not authorized')
|
||||
})
|
||||
|
||||
it('should not have joined the project room', function (done) {
|
||||
RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
error => {
|
||||
expect(error.message).to.equal('not found')
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when not authorized and web replies with a 403', function () {
|
||||
let joinProjectResponseReceived = false
|
||||
before(function (done) {
|
||||
async.series(
|
||||
[
|
||||
cb => {
|
||||
FixturesManager.setUpProject(
|
||||
{
|
||||
project_id: '403403403403403403403403', // forbidden
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, err => {
|
||||
this.error = err
|
||||
cb()
|
||||
})
|
||||
this.client.on('joinProjectResponse', () => {
|
||||
joinProjectResponseReceived = true
|
||||
cb()
|
||||
})
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should not emit joinProjectResponse', function () {
|
||||
expect(joinProjectResponseReceived).to.equal(false)
|
||||
})
|
||||
|
||||
it('should have disconnected the client', function () {
|
||||
expect(this.client.socket.connected).to.equal(false)
|
||||
})
|
||||
|
||||
it('should return an error', function () {
|
||||
this.error.message.should.equal('not authorized')
|
||||
})
|
||||
|
||||
it('should not have joined the project room', function (done) {
|
||||
RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
error => {
|
||||
expect(error.message).to.equal('not found')
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when deleted and web replies with a 404', function () {
|
||||
let joinProjectResponseReceived = false
|
||||
before(function (done) {
|
||||
async.series(
|
||||
[
|
||||
cb => {
|
||||
FixturesManager.setUpProject(
|
||||
{
|
||||
project_id: '404404404404404404404404', // not-found
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, err => {
|
||||
this.error = err
|
||||
cb()
|
||||
})
|
||||
this.client.on('joinProjectResponse', () => {
|
||||
joinProjectResponseReceived = true
|
||||
cb()
|
||||
})
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should not emit joinProjectResponse', function () {
|
||||
expect(joinProjectResponseReceived).to.equal(false)
|
||||
})
|
||||
|
||||
it('should have disconnected the client', function () {
|
||||
expect(this.client.socket.connected).to.equal(false)
|
||||
})
|
||||
|
||||
it('should return an error', function () {
|
||||
this.error.code.should.equal('ProjectNotFound')
|
||||
})
|
||||
|
||||
it('should not have joined the project room', function (done) {
|
||||
RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
error => {
|
||||
expect(error.message).to.equal('not found')
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when invalid', function () {
|
||||
let joinProjectResponseReceived = false
|
||||
before(function (done) {
|
||||
MockWebServer.joinProject.resetHistory()
|
||||
async.series(
|
||||
[
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect('invalid-id', err => {
|
||||
this.error = err
|
||||
cb()
|
||||
})
|
||||
this.client.on('joinProjectResponse', () => {
|
||||
joinProjectResponseReceived = true
|
||||
cb()
|
||||
})
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should not emit joinProjectResponse', function () {
|
||||
expect(joinProjectResponseReceived).to.equal(false)
|
||||
})
|
||||
|
||||
it('should have disconnected the client', function () {
|
||||
expect(this.client.socket.connected).to.equal(false)
|
||||
})
|
||||
|
||||
it('should return an invalid id error', function () {
|
||||
this.error.message.should.equal(
|
||||
'missing/bad ?projectId=... query flag on handshake'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not call to web', function () {
|
||||
MockWebServer.joinProject.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when over rate limit', function () {
|
||||
let joinProjectResponseReceived = false
|
||||
before(function (done) {
|
||||
async.series(
|
||||
[
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(
|
||||
'429429429429429429429429',
|
||||
err => {
|
||||
this.error = err
|
||||
cb()
|
||||
}
|
||||
)
|
||||
this.client.on('joinProjectResponse', () => {
|
||||
joinProjectResponseReceived = true
|
||||
cb()
|
||||
})
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should not emit joinProjectResponse', function () {
|
||||
expect(joinProjectResponseReceived).to.equal(false)
|
||||
})
|
||||
|
||||
it('should have disconnected the client', function () {
|
||||
expect(this.client.socket.connected).to.equal(false)
|
||||
})
|
||||
|
||||
it('should return a TooManyRequests error code', function () {
|
||||
this.error.message.should.equal('rate-limit hit when joining project')
|
||||
this.error.code.should.equal('TooManyRequests')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
178
services/real-time/test/acceptance/js/LeaveDocTests.js
Normal file
178
services/real-time/test/acceptance/js/LeaveDocTests.js
Normal file
@@ -0,0 +1,178 @@
|
||||
/* 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:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
const logger = require('@overleaf/logger')
|
||||
|
||||
const async = require('async')
|
||||
|
||||
describe('leaveDoc', function () {
|
||||
before(function () {
|
||||
this.lines = ['test', 'doc', 'lines']
|
||||
this.version = 42
|
||||
this.ops = ['mock', 'doc', 'ops']
|
||||
sinon.spy(logger, 'error')
|
||||
sinon.spy(logger, 'warn')
|
||||
sinon.spy(logger, 'debug')
|
||||
return (this.other_doc_id = FixturesManager.getRandomId())
|
||||
})
|
||||
|
||||
after(function () {
|
||||
logger.error.restore() // remove the spy
|
||||
logger.warn.restore()
|
||||
return logger.debug.restore()
|
||||
})
|
||||
|
||||
return describe('when joined to a doc', function () {
|
||||
beforeEach(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readAndWrite',
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit(
|
||||
'joinDoc',
|
||||
this.doc_id,
|
||||
(error, ...rest) => {
|
||||
;[...this.returnedArgs] = Array.from(rest)
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
describe('then leaving the doc', function () {
|
||||
beforeEach(function (done) {
|
||||
return this.client.emit('leaveDoc', this.doc_id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
return it('should have left the doc room', function (done) {
|
||||
return RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error) return done(error)
|
||||
expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(
|
||||
false
|
||||
)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('then leaving an invalid doc', function () {
|
||||
beforeEach(function (done) {
|
||||
return this.client.emit('leaveDoc', 'bad-id', error => {
|
||||
this.error = error
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
return it('should return an error', function () {
|
||||
return expect(this.error).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('when sending a leaveDoc request before the previous joinDoc request has completed', function () {
|
||||
beforeEach(function (done) {
|
||||
this.client.emit('leaveDoc', this.doc_id, () => {})
|
||||
this.client.emit('joinDoc', this.doc_id, () => {})
|
||||
return this.client.emit('leaveDoc', this.doc_id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not trigger an error', function () {
|
||||
return sinon.assert.neverCalledWith(
|
||||
logger.error,
|
||||
sinon.match.any,
|
||||
"not subscribed - shouldn't happen"
|
||||
)
|
||||
})
|
||||
|
||||
return it('should have left the doc room', function (done) {
|
||||
return RealTimeClient.getConnectedClient(
|
||||
this.client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error) return done(error)
|
||||
expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(
|
||||
false
|
||||
)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when sending a leaveDoc for a room the client has not joined ', function () {
|
||||
beforeEach(function (done) {
|
||||
return this.client.emit('leaveDoc', this.other_doc_id, error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
return it('should trigger a low level message only', function () {
|
||||
return sinon.assert.calledWith(
|
||||
logger.debug,
|
||||
sinon.match.any,
|
||||
'ignoring request from client to leave room it is not in'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
235
services/real-time/test/acceptance/js/LeaveProjectTests.js
Normal file
235
services/real-time/test/acceptance/js/LeaveProjectTests.js
Normal file
@@ -0,0 +1,235 @@
|
||||
/* eslint-disable
|
||||
no-throw-literal,
|
||||
*/
|
||||
// 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 RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const async = require('async')
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const rclient = redis.createClient(settings.redis.pubsub)
|
||||
|
||||
describe('leaveProject', function () {
|
||||
before(function (done) {
|
||||
return MockDocUpdaterServer.run(done)
|
||||
})
|
||||
|
||||
describe('with other clients in the project', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientB = RealTimeClient.connect(this.project_id, cb)
|
||||
|
||||
this.clientBDisconnectMessages = []
|
||||
return this.clientB.on(
|
||||
'clientTracking.clientDisconnected',
|
||||
data => {
|
||||
return this.clientBDisconnectMessages.push(data)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.clientA.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
cb => {
|
||||
return this.clientB.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
// leaveProject is called when the client disconnects
|
||||
this.clientA.on('disconnect', () => cb())
|
||||
return this.clientA.disconnect()
|
||||
},
|
||||
|
||||
cb => {
|
||||
// The API waits a little while before flushing changes
|
||||
return setTimeout(done, 1000)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should emit a disconnect message to the room', function () {
|
||||
return this.clientBDisconnectMessages.should.deep.equal([
|
||||
this.clientA.publicId,
|
||||
])
|
||||
})
|
||||
|
||||
it('should no longer list the client in connected users', function (done) {
|
||||
return this.clientB.emit(
|
||||
'clientTracking.getConnectedUsers',
|
||||
(error, users) => {
|
||||
if (error) return done(error)
|
||||
for (const user of Array.from(users)) {
|
||||
if (user.client_id === this.clientA.publicId) {
|
||||
throw 'Expected clientA to not be listed in connected users'
|
||||
}
|
||||
}
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should not flush the project to the document updater', function () {
|
||||
return MockDocUpdaterServer.deleteProject
|
||||
.calledWith(this.project_id)
|
||||
.should.equal(false)
|
||||
})
|
||||
|
||||
it('should remain subscribed to the editor-events channels', function (done) {
|
||||
rclient.pubsub('CHANNELS', (err, resp) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
resp.should.include(`editor-events:${this.project_id}`)
|
||||
return done()
|
||||
})
|
||||
return null
|
||||
})
|
||||
|
||||
return it('should remain subscribed to the applied-ops channels', function (done) {
|
||||
rclient.pubsub('CHANNELS', (err, resp) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
resp.should.include(`applied-ops:${this.doc_id}`)
|
||||
return done()
|
||||
})
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
return describe('with no other clients in the project', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA = RealTimeClient.connect(
|
||||
this.project_id,
|
||||
(error, project, privilegeLevel, protocolVersion) => {
|
||||
this.project = project
|
||||
this.privilegeLevel = privilegeLevel
|
||||
this.protocolVersion = protocolVersion
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
cb => {
|
||||
return this.clientA.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
// leaveProject is called when the client disconnects
|
||||
this.clientA.on('disconnect', () => cb())
|
||||
return this.clientA.disconnect()
|
||||
},
|
||||
|
||||
cb => {
|
||||
// The API waits a little while before flushing changes
|
||||
return setTimeout(done, 1000)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should flush the project to the document updater', function () {
|
||||
return MockDocUpdaterServer.deleteProject
|
||||
.calledWith(this.project_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should not subscribe to the editor-events channels anymore', function (done) {
|
||||
rclient.pubsub('CHANNELS', (err, resp) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
resp.should.not.include(`editor-events:${this.project_id}`)
|
||||
return done()
|
||||
})
|
||||
return null
|
||||
})
|
||||
|
||||
return it('should not subscribe to the applied-ops channels anymore', function (done) {
|
||||
rclient.pubsub('CHANNELS', (err, resp) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
resp.should.not.include(`applied-ops:${this.doc_id}`)
|
||||
return done()
|
||||
})
|
||||
return null
|
||||
})
|
||||
})
|
||||
})
|
494
services/real-time/test/acceptance/js/MatrixTests.js
Normal file
494
services/real-time/test/acceptance/js/MatrixTests.js
Normal file
@@ -0,0 +1,494 @@
|
||||
/*
|
||||
This test suite is a multi level matrix which allows us to test many cases
|
||||
with all kinds of setups.
|
||||
|
||||
Users/Actors are defined in USERS and are a low level entity that does connect
|
||||
to a real-time pod. A typical UserItem is:
|
||||
|
||||
someDescriptiveNameForTheTestSuite: {
|
||||
setup(cb) {
|
||||
// <setup session here>
|
||||
const options = { client: RealTimeClient.connect(), foo: 'bar' }
|
||||
cb(null, options)
|
||||
}
|
||||
}
|
||||
|
||||
Sessions are a set of actions that a User performs in the life-cycle of a
|
||||
real-time session, before they try something weird. A typical SessionItem is:
|
||||
|
||||
someOtherDescriptiveNameForTheTestSuite: {
|
||||
getActions(cb) {
|
||||
cb(null, [
|
||||
{ rpc: 'RPC_ENDPOINT', args: [...] }
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
Finally there are InvalidRequests which are the weird actions I hinted on in
|
||||
the Sessions section. The defined actions may be marked as 'failed' to denote
|
||||
that real-time rejects them with an (for this test) expected error.
|
||||
A typical InvalidRequestItem is:
|
||||
|
||||
joinOwnProject: {
|
||||
getActions(cb) {
|
||||
cb(null, [
|
||||
{ rpc: 'RPC_ENDPOINT', args: [...], failed: true }
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
There is additional meta-data that UserItems and SessionItems may use to skip
|
||||
certain areas of the matrix. Theses are:
|
||||
|
||||
- Has the User an own project that they join as part of the Session?
|
||||
UserItem: { hasOwnProject: true, setup(cb) { cb(null, { project_id, ... }) }}
|
||||
SessionItem: { needsOwnProject: true }
|
||||
*/
|
||||
|
||||
const { expect } = require('chai')
|
||||
const async = require('async')
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
const MockWebServer = require('./helpers/MockWebServer')
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const Keys = settings.redis.documentupdater.key_schema
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const rclient = redis.createClient(settings.redis.pubsub)
|
||||
|
||||
function getPendingUpdates(docId, cb) {
|
||||
rclient.lrange(Keys.pendingUpdates({ doc_id: docId }), 0, 10, cb)
|
||||
}
|
||||
function cleanupPreviousUpdates(docId, cb) {
|
||||
rclient.del(Keys.pendingUpdates({ doc_id: docId }), cb)
|
||||
}
|
||||
|
||||
describe('MatrixTests', function () {
|
||||
let privateProjectId,
|
||||
privateDocId,
|
||||
readWriteProjectId,
|
||||
readWriteDocId,
|
||||
readWriteAnonymousAccessToken
|
||||
|
||||
let privateClient
|
||||
before(function setupPrivateProject(done) {
|
||||
FixturesManager.setUpEditorSession(
|
||||
{ privilegeLevel: 'owner', publicAccessLevel: 'readAndWrite' },
|
||||
(err, { project_id: projectId, doc_id: docId }) => {
|
||||
if (err) return done(err)
|
||||
privateProjectId = projectId
|
||||
privateDocId = docId
|
||||
privateClient = RealTimeClient.connect(projectId, err => {
|
||||
if (err) return done(err)
|
||||
privateClient.emit('joinDoc', privateDocId, done)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
before(function setupReadWriteProject(done) {
|
||||
FixturesManager.setUpEditorSession(
|
||||
{
|
||||
publicAccess: 'readAndWrite',
|
||||
},
|
||||
(err, { project_id: projectId, doc_id: docId, anonymousAccessToken }) => {
|
||||
readWriteProjectId = projectId
|
||||
readWriteDocId = docId
|
||||
readWriteAnonymousAccessToken = anonymousAccessToken
|
||||
done(err)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const USER_SETUP = {
|
||||
anonymous: {
|
||||
setup(cb) {
|
||||
RealTimeClient.setAnonSession(
|
||||
readWriteProjectId,
|
||||
readWriteAnonymousAccessToken,
|
||||
err => {
|
||||
if (err) return cb(err)
|
||||
cb(null, {})
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
registered: {
|
||||
setup(cb) {
|
||||
const userId = FixturesManager.getRandomId()
|
||||
const user = { _id: userId, first_name: 'Joe', last_name: 'Bloggs' }
|
||||
RealTimeClient.setSession({ user }, err => {
|
||||
if (err) return cb(err)
|
||||
|
||||
MockWebServer.inviteUserToProject(
|
||||
readWriteProjectId,
|
||||
user,
|
||||
'readAndWrite'
|
||||
)
|
||||
cb(null, {
|
||||
user_id: userId,
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
registeredWithOwnedProject: {
|
||||
setup(cb) {
|
||||
FixturesManager.setUpEditorSession(
|
||||
{ privilegeLevel: 'owner' },
|
||||
(err, { project_id: projectId, user_id: userId, doc_id: docId }) => {
|
||||
if (err) return cb(err)
|
||||
|
||||
MockWebServer.inviteUserToProject(
|
||||
readWriteProjectId,
|
||||
{ _id: userId },
|
||||
'readAndWrite'
|
||||
)
|
||||
cb(null, {
|
||||
user_id: userId,
|
||||
project_id: projectId,
|
||||
doc_id: docId,
|
||||
})
|
||||
}
|
||||
)
|
||||
},
|
||||
hasOwnProject: true,
|
||||
},
|
||||
}
|
||||
|
||||
Object.entries(USER_SETUP).forEach(level0 => {
|
||||
const [userDescription, userItem] = level0
|
||||
let options, client
|
||||
|
||||
const SESSION_SETUP = {
|
||||
joinReadWriteProject: {
|
||||
getActions(cb) {
|
||||
cb(null, [{ connect: readWriteProjectId }])
|
||||
},
|
||||
needsOwnProject: false,
|
||||
},
|
||||
|
||||
joinReadWriteProjectAndDoc: {
|
||||
getActions(cb) {
|
||||
cb(null, [
|
||||
{ connect: readWriteProjectId },
|
||||
{ rpc: 'joinDoc', args: [readWriteDocId] },
|
||||
])
|
||||
},
|
||||
needsOwnProject: false,
|
||||
},
|
||||
|
||||
joinOwnProject: {
|
||||
getActions(cb) {
|
||||
cb(null, [{ connect: options.project_id }])
|
||||
},
|
||||
needsOwnProject: true,
|
||||
},
|
||||
|
||||
joinOwnProjectAndDoc: {
|
||||
getActions(cb) {
|
||||
cb(null, [
|
||||
{ connect: options.project_id },
|
||||
{ rpc: 'joinDoc', args: [options.doc_id] },
|
||||
])
|
||||
},
|
||||
needsOwnProject: true,
|
||||
},
|
||||
}
|
||||
|
||||
function performActions(getActions, done) {
|
||||
getActions((err, actions) => {
|
||||
if (err) return done(err)
|
||||
|
||||
async.eachSeries(
|
||||
actions,
|
||||
(action, next) => {
|
||||
const cb = (...returnedArgs) => {
|
||||
const error = returnedArgs.shift()
|
||||
if (action.fails) {
|
||||
expect(error).to.exist
|
||||
expect(returnedArgs).to.have.length(0)
|
||||
return next()
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
|
||||
if (action.connect) {
|
||||
client = RealTimeClient.connect(action.connect, cb)
|
||||
} else if (action.rpc) {
|
||||
if (client?.socket?.connected) {
|
||||
client.emit(action.rpc, ...action.args, cb)
|
||||
} else {
|
||||
cb(new Error('not connected!'))
|
||||
}
|
||||
} else {
|
||||
next(new Error('unexpected action'))
|
||||
}
|
||||
},
|
||||
done
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
describe(userDescription, function () {
|
||||
beforeEach(function userSetup(done) {
|
||||
userItem.setup((err, _options) => {
|
||||
if (err) return done(err)
|
||||
options = _options
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
Object.entries(SESSION_SETUP).forEach(level1 => {
|
||||
const [sessionSetupDescription, sessionSetupItem] = level1
|
||||
const INVALID_REQUESTS = {
|
||||
noop: {
|
||||
getActions(cb) {
|
||||
cb(null, [])
|
||||
},
|
||||
},
|
||||
|
||||
joinProjectWithBadAccessToken: {
|
||||
getActions(cb) {
|
||||
RealTimeClient.setAnonSession(
|
||||
privateProjectId,
|
||||
'invalid-access-token',
|
||||
err => {
|
||||
if (err) return cb(err)
|
||||
cb(null, [
|
||||
{
|
||||
connect: privateProjectId,
|
||||
fails: 1,
|
||||
},
|
||||
])
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
joinProjectWithDocId: {
|
||||
getActions(cb) {
|
||||
cb(null, [
|
||||
{
|
||||
connect: privateDocId,
|
||||
fails: 1,
|
||||
},
|
||||
])
|
||||
},
|
||||
},
|
||||
|
||||
joinDocWithDocId: {
|
||||
getActions(cb) {
|
||||
cb(null, [{ rpc: 'joinDoc', args: [privateDocId], fails: 1 }])
|
||||
},
|
||||
},
|
||||
|
||||
joinProjectWithProjectId: {
|
||||
getActions(cb) {
|
||||
cb(null, [
|
||||
{
|
||||
connect: privateProjectId,
|
||||
fails: 1,
|
||||
},
|
||||
])
|
||||
},
|
||||
},
|
||||
|
||||
joinDocWithProjectId: {
|
||||
getActions(cb) {
|
||||
cb(null, [{ rpc: 'joinDoc', args: [privateProjectId], fails: 1 }])
|
||||
},
|
||||
},
|
||||
|
||||
joinProjectWithProjectIdThenJoinDocWithDocId: {
|
||||
getActions(cb) {
|
||||
cb(null, [
|
||||
{
|
||||
connect: privateProjectId,
|
||||
fails: 1,
|
||||
},
|
||||
{ rpc: 'joinDoc', args: [privateDocId], fails: 1 },
|
||||
])
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// skip some areas of the matrix
|
||||
// - some Users do not have an own project
|
||||
const skip = sessionSetupItem.needsOwnProject && !userItem.hasOwnProject
|
||||
|
||||
describe(sessionSetupDescription, function () {
|
||||
beforeEach(function performSessionActions(done) {
|
||||
if (skip) return this.skip()
|
||||
performActions(sessionSetupItem.getActions, done)
|
||||
})
|
||||
|
||||
Object.entries(INVALID_REQUESTS).forEach(level2 => {
|
||||
const [InvalidRequestDescription, InvalidRequestItem] = level2
|
||||
describe(InvalidRequestDescription, function () {
|
||||
beforeEach(function performInvalidRequests(done) {
|
||||
performActions(InvalidRequestItem.getActions, done)
|
||||
})
|
||||
|
||||
describe('rooms', function () {
|
||||
it('should not add the user into the privateProject room', function (done) {
|
||||
RealTimeClient.getConnectedClient(
|
||||
client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error?.message === 'not found') return done() // disconnected
|
||||
if (error) return done(error)
|
||||
expect(client.rooms).to.not.include(privateProjectId)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should not add the user into the privateDoc room', function (done) {
|
||||
RealTimeClient.getConnectedClient(
|
||||
client.socket.sessionid,
|
||||
(error, client) => {
|
||||
if (error?.message === 'not found') return done() // disconnected
|
||||
if (error) return done(error)
|
||||
expect(client.rooms).to.not.include(privateDocId)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('receive updates', function () {
|
||||
const receivedMessages = []
|
||||
beforeEach(function publishAnUpdateInRedis(done) {
|
||||
const update = {
|
||||
doc_id: privateDocId,
|
||||
op: {
|
||||
meta: { source: privateClient.publicId },
|
||||
v: 42,
|
||||
doc: privateDocId,
|
||||
op: [{ i: 'foo', p: 50 }],
|
||||
},
|
||||
}
|
||||
client.on('otUpdateApplied', update => {
|
||||
receivedMessages.push(update)
|
||||
})
|
||||
privateClient.once('otUpdateApplied', () => {
|
||||
setTimeout(done, 10)
|
||||
})
|
||||
rclient.publish('applied-ops', JSON.stringify(update))
|
||||
})
|
||||
|
||||
it('should send nothing to client', function () {
|
||||
expect(receivedMessages).to.have.length(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('receive messages from web', function () {
|
||||
const receivedMessages = []
|
||||
beforeEach(function publishAMessageInRedis(done) {
|
||||
const event = {
|
||||
room_id: privateProjectId,
|
||||
message: 'removeEntity',
|
||||
payload: ['foo', 'convertDocToFile'],
|
||||
_id: 'web:123',
|
||||
}
|
||||
client.on('removeEntity', (...args) => {
|
||||
receivedMessages.push(args)
|
||||
})
|
||||
privateClient.once('removeEntity', () => {
|
||||
setTimeout(done, 10)
|
||||
})
|
||||
rclient.publish('editor-events', JSON.stringify(event))
|
||||
})
|
||||
|
||||
it('should send nothing to client', function () {
|
||||
expect(receivedMessages).to.have.length(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('send updates', function () {
|
||||
let receivedArgs, submittedUpdates, update
|
||||
|
||||
beforeEach(function cleanup(done) {
|
||||
cleanupPreviousUpdates(privateDocId, done)
|
||||
})
|
||||
|
||||
beforeEach(function setupUpdateFields() {
|
||||
update = {
|
||||
doc_id: privateDocId,
|
||||
op: {
|
||||
v: 43,
|
||||
lastV: 42,
|
||||
doc: privateDocId,
|
||||
op: [{ i: 'foo', p: 50 }],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(function sendAsUser(done) {
|
||||
if (!client?.socket?.connected) {
|
||||
// disconnected clients cannot emit messages
|
||||
return this.skip()
|
||||
}
|
||||
const userUpdate = Object.assign({}, update, {
|
||||
hash: 'user',
|
||||
})
|
||||
|
||||
client.emit(
|
||||
'applyOtUpdate',
|
||||
privateDocId,
|
||||
userUpdate,
|
||||
(...args) => {
|
||||
receivedArgs = args
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
beforeEach(function sendAsPrivateUserForReferenceOp(done) {
|
||||
const privateUpdate = Object.assign({}, update, {
|
||||
hash: 'private',
|
||||
})
|
||||
|
||||
privateClient.emit(
|
||||
'applyOtUpdate',
|
||||
privateDocId,
|
||||
privateUpdate,
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
beforeEach(function fetchPendingOps(done) {
|
||||
getPendingUpdates(privateDocId, (err, updates) => {
|
||||
submittedUpdates = updates
|
||||
done(err)
|
||||
})
|
||||
})
|
||||
|
||||
it('should error out trying to send', function () {
|
||||
expect(receivedArgs).to.have.length(1)
|
||||
expect(receivedArgs[0]).to.have.property('message')
|
||||
// we are using an old version of chai: 1.9.2
|
||||
// TypeError: expect(...).to.be.oneOf is not a function
|
||||
expect(
|
||||
[
|
||||
'no project_id found on client',
|
||||
'not authorized',
|
||||
].includes(receivedArgs[0].message)
|
||||
).to.equal(true)
|
||||
})
|
||||
|
||||
it('should submit the private users message only', function () {
|
||||
expect(submittedUpdates).to.have.length(1)
|
||||
const update = JSON.parse(submittedUpdates[0])
|
||||
expect(update.hash).to.equal('private')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
348
services/real-time/test/acceptance/js/PubSubRace.js
Normal file
348
services/real-time/test/acceptance/js/PubSubRace.js
Normal file
@@ -0,0 +1,348 @@
|
||||
/* 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 RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const async = require('async')
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const rclient = redis.createClient(settings.redis.pubsub)
|
||||
|
||||
describe('PubSubRace', function () {
|
||||
before(function (done) {
|
||||
return MockDocUpdaterServer.run(done)
|
||||
})
|
||||
|
||||
describe('when the client leaves a doc before joinDoc completes', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA = RealTimeClient.connect(
|
||||
this.project_id,
|
||||
(error, project, privilegeLevel, protocolVersion) => {
|
||||
this.project = project
|
||||
this.privilegeLevel = privilegeLevel
|
||||
this.protocolVersion = protocolVersion
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA.emit('joinDoc', this.doc_id, () => {})
|
||||
// leave before joinDoc completes
|
||||
return this.clientA.emit('leaveDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
// wait for subscribe and unsubscribe
|
||||
return setTimeout(cb, 100)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should not subscribe to the applied-ops channels anymore', function (done) {
|
||||
rclient.pubsub('CHANNELS', (err, resp) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
resp.should.not.include(`applied-ops:${this.doc_id}`)
|
||||
return done()
|
||||
})
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the client emits joinDoc and leaveDoc requests frequently and leaves eventually', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA = RealTimeClient.connect(
|
||||
this.project_id,
|
||||
(error, project, privilegeLevel, protocolVersion) => {
|
||||
this.project = project
|
||||
this.privilegeLevel = privilegeLevel
|
||||
this.protocolVersion = protocolVersion
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA.emit('joinDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('leaveDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('joinDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('leaveDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('joinDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('leaveDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('joinDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('leaveDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('joinDoc', this.doc_id, () => {})
|
||||
return this.clientA.emit('leaveDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
// wait for subscribe and unsubscribe
|
||||
return setTimeout(cb, 100)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should not subscribe to the applied-ops channels anymore', function (done) {
|
||||
rclient.pubsub('CHANNELS', (err, resp) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
resp.should.not.include(`applied-ops:${this.doc_id}`)
|
||||
return done()
|
||||
})
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the client emits joinDoc and leaveDoc requests frequently and remains in the doc', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA = RealTimeClient.connect(
|
||||
this.project_id,
|
||||
(error, project, privilegeLevel, protocolVersion) => {
|
||||
this.project = project
|
||||
this.privilegeLevel = privilegeLevel
|
||||
this.protocolVersion = protocolVersion
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA.emit('joinDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('leaveDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('joinDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('leaveDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('joinDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('leaveDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('joinDoc', this.doc_id, () => {})
|
||||
this.clientA.emit('leaveDoc', this.doc_id, () => {})
|
||||
return this.clientA.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
// wait for subscribe and unsubscribe
|
||||
return setTimeout(cb, 100)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should subscribe to the applied-ops channels', function (done) {
|
||||
rclient.pubsub('CHANNELS', (err, resp) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
resp.should.include(`applied-ops:${this.doc_id}`)
|
||||
return done()
|
||||
})
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when the client disconnects before joinDoc completes', function () {
|
||||
before(function (done) {
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA = RealTimeClient.connect(
|
||||
this.project_id,
|
||||
(error, project, privilegeLevel, protocolVersion) => {
|
||||
this.project = project
|
||||
this.privilegeLevel = privilegeLevel
|
||||
this.protocolVersion = protocolVersion
|
||||
return cb(error)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
let joinDocCompleted = false
|
||||
this.clientA.emit(
|
||||
'joinDoc',
|
||||
this.doc_id,
|
||||
() => (joinDocCompleted = true)
|
||||
)
|
||||
// leave before joinDoc completes
|
||||
return setTimeout(
|
||||
() => {
|
||||
if (joinDocCompleted) {
|
||||
return cb(new Error('joinDocCompleted -- lower timeout'))
|
||||
}
|
||||
this.clientA.on('disconnect', () => cb())
|
||||
return this.clientA.disconnect()
|
||||
},
|
||||
// socket.io processes joinDoc and disconnect with different delays:
|
||||
// - joinDoc goes through two process.nextTick
|
||||
// - disconnect goes through one process.nextTick
|
||||
// We have to inject the disconnect event into a different event loop
|
||||
// cycle.
|
||||
3
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
// wait for subscribe and unsubscribe
|
||||
return setTimeout(cb, 100)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should not subscribe to the editor-events channels anymore', function (done) {
|
||||
rclient.pubsub('CHANNELS', (err, resp) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
resp.should.not.include(`editor-events:${this.project_id}`)
|
||||
return done()
|
||||
})
|
||||
return null
|
||||
})
|
||||
|
||||
return it('should not subscribe to the applied-ops channels anymore', function (done) {
|
||||
rclient.pubsub('CHANNELS', (err, resp) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
resp.should.not.include(`applied-ops:${this.doc_id}`)
|
||||
return done()
|
||||
})
|
||||
return null
|
||||
})
|
||||
})
|
||||
})
|
367
services/real-time/test/acceptance/js/ReceiveEditorEventTests.js
Normal file
367
services/real-time/test/acceptance/js/ReceiveEditorEventTests.js
Normal file
@@ -0,0 +1,367 @@
|
||||
/* eslint-disable
|
||||
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
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const async = require('async')
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const rclient = redis.createClient(settings.redis.pubsub)
|
||||
|
||||
describe('receiveEditorEvent', function () {
|
||||
beforeEach(function (done) {
|
||||
this.lines = ['test', 'doc', 'lines']
|
||||
this.version = 42
|
||||
this.ops = ['mock', 'doc', 'ops']
|
||||
|
||||
/**
|
||||
* We will set up a project, a doc, and three users: the owner, user 'a' and user 'b'
|
||||
*/
|
||||
this.project_id = null
|
||||
this.doc_id = null
|
||||
|
||||
this.owner_user_id = null
|
||||
this.owner_client = null
|
||||
|
||||
this.user_a_id = null
|
||||
this.user_a_client = null
|
||||
|
||||
this.user_b_id = null
|
||||
this.user_b_client = null
|
||||
|
||||
this.user_c_id = null
|
||||
this.user_c_client = null
|
||||
|
||||
async.series(
|
||||
[
|
||||
/**
|
||||
* Create the project, doc, and owner
|
||||
*/
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: { name: 'Test Project' },
|
||||
userMetadata: { isInvitedMember: true },
|
||||
},
|
||||
(error, { user_id: userId, project_id: projectId }) => {
|
||||
if (error) return done(error)
|
||||
this.owner_user_id = userId
|
||||
this.project_id = projectId
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Connect owner to project/doc
|
||||
*/
|
||||
cb => {
|
||||
this.owner_client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.owner_client.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
/**
|
||||
* add user_a to project, as an invited member
|
||||
*/
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readAndWrite',
|
||||
project_id: this.project_id,
|
||||
userMetadata: { isTokenMember: false, isInvitedMember: true },
|
||||
},
|
||||
(error, { user_id: userIdSecond }) => {
|
||||
if (error) return done(error)
|
||||
this.user_a_id = userIdSecond
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Connect user_a to project/doc
|
||||
*/
|
||||
cb => {
|
||||
this.user_a_client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
cb => {
|
||||
return this.user_a_client.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
/**
|
||||
* Set up user_b, as a token-access/link-sharing user
|
||||
*/
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readAndWrite',
|
||||
project_id: this.project_id,
|
||||
userMetadata: { isTokenMember: true, isInvitedMember: false },
|
||||
},
|
||||
(error, { user_id: userIdThird }) => {
|
||||
if (error) return done(error)
|
||||
this.user_b_id = userIdThird
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Connect user_b to project/doc
|
||||
*/
|
||||
cb => {
|
||||
this.user_b_client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
cb => {
|
||||
return this.user_b_client.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
/**
|
||||
* Set up user_c, as a 'restricted' user (anonymous read-only link-sharing)
|
||||
*/
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'readAndWrite',
|
||||
project_id: this.project_id,
|
||||
userMetadata: {
|
||||
isTokenMember: false,
|
||||
isInvitedMember: false,
|
||||
isRestrictedUser: true,
|
||||
},
|
||||
},
|
||||
(error, { user_id: userIdFourth }) => {
|
||||
if (error) return done(error)
|
||||
this.user_c_id = userIdFourth
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Connect user_c to project/doc
|
||||
*/
|
||||
cb => {
|
||||
this.user_c_client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
cb => {
|
||||
return this.user_c_client.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
// --------------
|
||||
|
||||
/**
|
||||
* Listen for updates
|
||||
*/
|
||||
cb => {
|
||||
this.owner_updates = []
|
||||
this.user_a_updates = []
|
||||
this.user_b_updates = []
|
||||
this.user_c_updates = []
|
||||
|
||||
const eventNames = [
|
||||
'userRemovedFromProject',
|
||||
'project:publicAccessLevel:changed',
|
||||
'project:access:revoked',
|
||||
]
|
||||
|
||||
for (const eventName of eventNames) {
|
||||
this.owner_client.on(eventName, update =>
|
||||
this.owner_updates.push({ [eventName]: update })
|
||||
)
|
||||
this.user_a_client.on(eventName, update =>
|
||||
this.user_a_updates.push({ [eventName]: update })
|
||||
)
|
||||
this.user_b_client.on(eventName, update =>
|
||||
this.user_b_updates.push({ [eventName]: update })
|
||||
)
|
||||
this.user_c_client.on(eventName, update =>
|
||||
this.user_c_updates.push({ [eventName]: update })
|
||||
)
|
||||
}
|
||||
|
||||
return cb()
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
if (this.owner_client) {
|
||||
this.owner_client.disconnect()
|
||||
}
|
||||
if (this.user_a_client) {
|
||||
this.user_a_client.disconnect()
|
||||
}
|
||||
if (this.user_b_client) {
|
||||
this.user_b_client.disconnect()
|
||||
}
|
||||
if (this.user_c_client) {
|
||||
this.user_c_client.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
describe('event: project:publicAccessLevel:changed, set to private', function () {
|
||||
beforeEach(function (done) {
|
||||
/**
|
||||
* We turn off link sharing
|
||||
*/
|
||||
rclient.publish(
|
||||
'editor-events',
|
||||
JSON.stringify({
|
||||
room_id: this.project_id,
|
||||
message: 'project:publicAccessLevel:changed',
|
||||
payload: [{ newAccessLevel: 'private' }],
|
||||
})
|
||||
)
|
||||
setTimeout(done, 200)
|
||||
})
|
||||
|
||||
it('should disconnect the token-access user, and restricted users', function () {
|
||||
expect(this.user_b_client.socket.connected).to.equal(false)
|
||||
expect(this.user_c_client.socket.connected).to.equal(false)
|
||||
})
|
||||
|
||||
it('should not disconnect the other users', function () {
|
||||
expect(this.owner_client.socket.connected).to.equal(true)
|
||||
expect(this.user_a_client.socket.connected).to.equal(true)
|
||||
})
|
||||
|
||||
it('should send the event to the remaining connected clients', function () {
|
||||
expect(this.owner_updates).to.deep.equal([
|
||||
{ 'project:publicAccessLevel:changed': { newAccessLevel: 'private' } },
|
||||
])
|
||||
|
||||
expect(this.user_a_updates).to.deep.equal([
|
||||
{ 'project:publicAccessLevel:changed': { newAccessLevel: 'private' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('should send a project:access:revoked message to the disconnected clients', function () {
|
||||
expect(this.user_b_updates).to.deep.equal([
|
||||
{ 'project:access:revoked': undefined },
|
||||
])
|
||||
expect(this.user_c_updates).to.deep.equal([
|
||||
{ 'project:access:revoked': undefined },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('event: project:publicAccessLevel:changed, set to tokenBased', function () {
|
||||
beforeEach(function (done) {
|
||||
/**
|
||||
* We turn on link sharing
|
||||
*/
|
||||
rclient.publish(
|
||||
'editor-events',
|
||||
JSON.stringify({
|
||||
room_id: this.project_id,
|
||||
message: 'project:publicAccessLevel:changed',
|
||||
payload: [{ newAccessLevel: 'tokenBased' }],
|
||||
})
|
||||
)
|
||||
setTimeout(done, 200)
|
||||
})
|
||||
|
||||
it('should not disconnect anyone', function () {
|
||||
expect(this.owner_client.socket.connected).to.equal(true)
|
||||
expect(this.user_a_client.socket.connected).to.equal(true)
|
||||
expect(this.user_b_client.socket.connected).to.equal(true)
|
||||
expect(this.user_c_client.socket.connected).to.equal(true)
|
||||
})
|
||||
|
||||
it('should send the event to all non-restricted clients', function () {
|
||||
expect(this.owner_updates).to.deep.equal([
|
||||
{
|
||||
'project:publicAccessLevel:changed': { newAccessLevel: 'tokenBased' },
|
||||
},
|
||||
])
|
||||
|
||||
expect(this.user_a_updates).to.deep.equal([
|
||||
{
|
||||
'project:publicAccessLevel:changed': { newAccessLevel: 'tokenBased' },
|
||||
},
|
||||
])
|
||||
|
||||
expect(this.user_b_updates).to.deep.equal([
|
||||
{
|
||||
'project:publicAccessLevel:changed': { newAccessLevel: 'tokenBased' },
|
||||
},
|
||||
])
|
||||
// restricted users don't receive this type of message
|
||||
expect(this.user_c_updates.length).to.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('event: userRemovedFromProject', function () {
|
||||
let removedUserId
|
||||
beforeEach(function (done) {
|
||||
/**
|
||||
* We remove user_a from the project
|
||||
*/
|
||||
removedUserId = `${this.user_a_id}`
|
||||
rclient.publish(
|
||||
'editor-events',
|
||||
JSON.stringify({
|
||||
room_id: this.project_id,
|
||||
message: 'userRemovedFromProject',
|
||||
payload: [removedUserId],
|
||||
})
|
||||
)
|
||||
setTimeout(done, 200)
|
||||
})
|
||||
|
||||
it('should disconnect the removed user', function () {
|
||||
expect(this.user_a_client.socket.connected).to.equal(false)
|
||||
})
|
||||
|
||||
it('should not disconnect the other users', function () {
|
||||
expect(this.owner_client.socket.connected).to.equal(true)
|
||||
expect(this.user_b_client.socket.connected).to.equal(true)
|
||||
})
|
||||
|
||||
it('should send the event to the remaining connected clients', function () {
|
||||
expect(this.owner_updates).to.deep.equal([
|
||||
{ userRemovedFromProject: removedUserId },
|
||||
])
|
||||
|
||||
expect(this.user_b_updates).to.deep.equal([
|
||||
{ userRemovedFromProject: removedUserId },
|
||||
])
|
||||
})
|
||||
|
||||
it('should send a project:access:revoked message to the disconnected clients', function () {
|
||||
expect(this.user_a_updates).to.deep.equal([
|
||||
{ 'project:access:revoked': undefined },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
312
services/real-time/test/acceptance/js/ReceiveUpdateTests.js
Normal file
312
services/real-time/test/acceptance/js/ReceiveUpdateTests.js
Normal file
@@ -0,0 +1,312 @@
|
||||
/* eslint-disable
|
||||
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
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const MockWebServer = require('./helpers/MockWebServer')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
const async = require('async')
|
||||
|
||||
const settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const rclient = redis.createClient(settings.redis.pubsub)
|
||||
|
||||
describe('receiveUpdate', function () {
|
||||
beforeEach(function (done) {
|
||||
this.lines = ['test', 'doc', 'lines']
|
||||
this.version = 42
|
||||
this.ops = ['mock', 'doc', 'ops']
|
||||
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: { name: 'Test Project' },
|
||||
},
|
||||
(error, { user_id: userId, project_id: projectId }) => {
|
||||
if (error) return done(error)
|
||||
this.user_id = userId
|
||||
this.project_id = projectId
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docId }) => {
|
||||
this.doc_id = docId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientA = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientB = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.clientA.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.clientB.emit('joinDoc', this.doc_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: { name: 'Test Project' },
|
||||
},
|
||||
(error, { user_id: userIdSecond, project_id: projectIdSecond }) => {
|
||||
if (error) return done(error)
|
||||
this.user_id_second = userIdSecond
|
||||
this.project_id_second = projectIdSecond
|
||||
return cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return FixturesManager.setUpDoc(
|
||||
this.project_id_second,
|
||||
{ lines: this.lines, version: this.version, ops: this.ops },
|
||||
(e, { doc_id: docIdSecond }) => {
|
||||
this.doc_id_second = docIdSecond
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientC = RealTimeClient.connect(this.project_id_second, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.clientC.emit('joinDoc', this.doc_id_second, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.clientAUpdates = []
|
||||
this.clientA.on('otUpdateApplied', update =>
|
||||
this.clientAUpdates.push(update)
|
||||
)
|
||||
this.clientBUpdates = []
|
||||
this.clientB.on('otUpdateApplied', update =>
|
||||
this.clientBUpdates.push(update)
|
||||
)
|
||||
this.clientCUpdates = []
|
||||
this.clientC.on('otUpdateApplied', update =>
|
||||
this.clientCUpdates.push(update)
|
||||
)
|
||||
|
||||
this.clientAErrors = []
|
||||
this.clientA.on('otUpdateError', error =>
|
||||
this.clientAErrors.push(error)
|
||||
)
|
||||
this.clientBErrors = []
|
||||
this.clientB.on('otUpdateError', error =>
|
||||
this.clientBErrors.push(error)
|
||||
)
|
||||
this.clientCErrors = []
|
||||
this.clientC.on('otUpdateError', error =>
|
||||
this.clientCErrors.push(error)
|
||||
)
|
||||
return cb()
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
if (this.clientA != null) {
|
||||
this.clientA.disconnect()
|
||||
}
|
||||
if (this.clientB != null) {
|
||||
this.clientB.disconnect()
|
||||
}
|
||||
return this.clientC != null ? this.clientC.disconnect() : undefined
|
||||
})
|
||||
|
||||
describe('with an update from clientA', function () {
|
||||
beforeEach(function (done) {
|
||||
this.update = {
|
||||
doc_id: this.doc_id,
|
||||
op: {
|
||||
meta: {
|
||||
source: this.clientA.publicId,
|
||||
},
|
||||
v: this.version,
|
||||
doc: this.doc_id,
|
||||
op: [{ i: 'foo', p: 50 }],
|
||||
},
|
||||
}
|
||||
rclient.publish('applied-ops', JSON.stringify(this.update))
|
||||
return setTimeout(done, 200)
|
||||
}) // Give clients time to get message
|
||||
|
||||
it('should send the full op to clientB', function () {
|
||||
return this.clientBUpdates.should.deep.equal([this.update.op])
|
||||
})
|
||||
|
||||
it('should send an ack to clientA', function () {
|
||||
return this.clientAUpdates.should.deep.equal([
|
||||
{
|
||||
v: this.version,
|
||||
doc: this.doc_id,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
return it('should send nothing to clientC', function () {
|
||||
return this.clientCUpdates.should.deep.equal([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an update from clientC', function () {
|
||||
beforeEach(function (done) {
|
||||
this.update = {
|
||||
doc_id: this.doc_id_second,
|
||||
op: {
|
||||
meta: {
|
||||
source: this.clientC.publicId,
|
||||
},
|
||||
v: this.version,
|
||||
doc: this.doc_id_second,
|
||||
op: [{ i: 'update from clientC', p: 50 }],
|
||||
},
|
||||
}
|
||||
rclient.publish('applied-ops', JSON.stringify(this.update))
|
||||
return setTimeout(done, 200)
|
||||
}) // Give clients time to get message
|
||||
|
||||
it('should send nothing to clientA', function () {
|
||||
return this.clientAUpdates.should.deep.equal([])
|
||||
})
|
||||
|
||||
it('should send nothing to clientB', function () {
|
||||
return this.clientBUpdates.should.deep.equal([])
|
||||
})
|
||||
|
||||
return it('should send an ack to clientC', function () {
|
||||
return this.clientCUpdates.should.deep.equal([
|
||||
{
|
||||
v: this.version,
|
||||
doc: this.doc_id_second,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an update from a remote client for project 1', function () {
|
||||
beforeEach(function (done) {
|
||||
this.update = {
|
||||
doc_id: this.doc_id,
|
||||
op: {
|
||||
meta: {
|
||||
source: 'this-is-a-remote-client-id',
|
||||
},
|
||||
v: this.version,
|
||||
doc: this.doc_id,
|
||||
op: [{ i: 'foo', p: 50 }],
|
||||
},
|
||||
}
|
||||
rclient.publish('applied-ops', JSON.stringify(this.update))
|
||||
return setTimeout(done, 200)
|
||||
}) // Give clients time to get message
|
||||
|
||||
it('should send the full op to clientA', function () {
|
||||
return this.clientAUpdates.should.deep.equal([this.update.op])
|
||||
})
|
||||
|
||||
it('should send the full op to clientB', function () {
|
||||
return this.clientBUpdates.should.deep.equal([this.update.op])
|
||||
})
|
||||
|
||||
return it('should send nothing to clientC', function () {
|
||||
return this.clientCUpdates.should.deep.equal([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an error for the first project', function () {
|
||||
beforeEach(function (done) {
|
||||
rclient.publish(
|
||||
'applied-ops',
|
||||
JSON.stringify({
|
||||
doc_id: this.doc_id,
|
||||
error: (this.error = 'something went wrong'),
|
||||
})
|
||||
)
|
||||
return setTimeout(done, 200)
|
||||
}) // Give clients time to get message
|
||||
|
||||
it('should send the error to the clients in the first project', function () {
|
||||
this.clientAErrors.should.deep.equal([this.error])
|
||||
return this.clientBErrors.should.deep.equal([this.error])
|
||||
})
|
||||
|
||||
it('should not send any errors to the client in the second project', function () {
|
||||
return this.clientCErrors.should.deep.equal([])
|
||||
})
|
||||
|
||||
it('should disconnect the clients of the first project', function () {
|
||||
this.clientA.socket.connected.should.equal(false)
|
||||
return this.clientB.socket.connected.should.equal(false)
|
||||
})
|
||||
|
||||
return it('should not disconnect the client in the second project', function () {
|
||||
return this.clientC.socket.connected.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
return describe('with an error for the second project', function () {
|
||||
beforeEach(function (done) {
|
||||
rclient.publish(
|
||||
'applied-ops',
|
||||
JSON.stringify({
|
||||
doc_id: this.doc_id_second,
|
||||
error: (this.error = 'something went wrong'),
|
||||
})
|
||||
)
|
||||
return setTimeout(done, 200)
|
||||
}) // Give clients time to get message
|
||||
|
||||
it('should not send any errors to the clients in the first project', function () {
|
||||
this.clientAErrors.should.deep.equal([])
|
||||
return this.clientBErrors.should.deep.equal([])
|
||||
})
|
||||
|
||||
it('should send the error to the client in the second project', function () {
|
||||
return this.clientCErrors.should.deep.equal([this.error])
|
||||
})
|
||||
|
||||
it('should not disconnect the clients of the first project', function () {
|
||||
this.clientA.socket.connected.should.equal(true)
|
||||
return this.clientB.socket.connected.should.equal(true)
|
||||
})
|
||||
|
||||
return it('should disconnect the client in the second project', function () {
|
||||
return this.clientC.socket.connected.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
105
services/real-time/test/acceptance/js/RouterTests.js
Normal file
105
services/real-time/test/acceptance/js/RouterTests.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// 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 async = require('async')
|
||||
const { expect } = require('chai')
|
||||
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
|
||||
describe('Router', function () {
|
||||
return describe('joinProject', function () {
|
||||
describe('when there is no callback provided', function () {
|
||||
after(function () {
|
||||
return process.removeListener('unhandledRejection', this.onUnhandled)
|
||||
})
|
||||
|
||||
before(function (done) {
|
||||
this.onUnhandled = error => done(error)
|
||||
process.on('unhandledRejection', this.onUnhandled)
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return setTimeout(cb, 100)
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should keep on going', function () {
|
||||
return expect('still running').to.exist
|
||||
})
|
||||
})
|
||||
|
||||
return describe('when there are too many arguments', function () {
|
||||
after(function () {
|
||||
return process.removeListener('unhandledRejection', this.onUnhandled)
|
||||
})
|
||||
|
||||
before(function (done) {
|
||||
this.onUnhandled = error => done(error)
|
||||
process.on('unhandledRejection', this.onUnhandled)
|
||||
return async.series(
|
||||
[
|
||||
cb => {
|
||||
return FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
project: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
},
|
||||
(e, { project_id: projectId, user_id: userId }) => {
|
||||
this.project_id = projectId
|
||||
this.user_id = userId
|
||||
return cb(e)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cb => {
|
||||
this.client = RealTimeClient.connect(this.project_id, cb)
|
||||
},
|
||||
|
||||
cb => {
|
||||
return this.client.emit('joinDoc', 1, 2, 3, 4, 5, error => {
|
||||
this.error = error
|
||||
return cb()
|
||||
})
|
||||
},
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
return it('should return an error message', function () {
|
||||
return expect(this.error.message).to.equal('unexpected arguments')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
140
services/real-time/test/acceptance/js/SessionSocketsTests.js
Normal file
140
services/real-time/test/acceptance/js/SessionSocketsTests.js
Normal file
@@ -0,0 +1,140 @@
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const signature = require('cookie-signature')
|
||||
const { expect } = require('chai')
|
||||
|
||||
describe('SessionSockets', function () {
|
||||
beforeEach(function (done) {
|
||||
FixturesManager.setUpProject(
|
||||
{
|
||||
privilegeLevel: 'owner',
|
||||
},
|
||||
(err, options) => {
|
||||
if (err) return done(err)
|
||||
|
||||
this.checkSocket = function (fn) {
|
||||
RealTimeClient.connect(options.project_id, fn)
|
||||
}
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('without cookies', function () {
|
||||
beforeEach(function () {
|
||||
RealTimeClient.cookie = null
|
||||
})
|
||||
|
||||
it('should return a lookup error', function (done) {
|
||||
this.checkSocket(error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('invalid session')
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a different cookie', function () {
|
||||
beforeEach(function () {
|
||||
RealTimeClient.cookie = 'some.key=someValue'
|
||||
})
|
||||
|
||||
it('should return a lookup error', function (done) {
|
||||
this.checkSocket(error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('invalid session')
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with an invalid cookie', function () {
|
||||
beforeEach(function (done) {
|
||||
RealTimeClient.setSession({}, error => {
|
||||
if (error) {
|
||||
return done(error)
|
||||
}
|
||||
RealTimeClient.cookie = `${
|
||||
Settings.cookieName
|
||||
}=${RealTimeClient.cookie.slice(17, 49)}`
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a lookup error', function (done) {
|
||||
this.checkSocket(error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('invalid session')
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a valid cookie and no matching session', function () {
|
||||
beforeEach(function () {
|
||||
RealTimeClient.cookie = `${Settings.cookieName}=unknownId`
|
||||
})
|
||||
|
||||
it('should return a lookup error', function (done) {
|
||||
this.checkSocket(error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('invalid session')
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a valid cookie and a matching session', function () {
|
||||
it('should not return an error', function (done) {
|
||||
this.checkSocket(error => {
|
||||
expect(error).to.not.exist
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a cookie signed by the fallback key and a matching session', function () {
|
||||
beforeEach(function () {
|
||||
RealTimeClient.cookie =
|
||||
RealTimeClient.cookieSignedWith.sessionSecretFallback
|
||||
})
|
||||
it('should not return an error', function (done) {
|
||||
this.checkSocket(error => {
|
||||
expect(error).to.not.exist
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a cookie signed by the upcoming key and a matching session', function () {
|
||||
beforeEach(function () {
|
||||
RealTimeClient.cookie =
|
||||
RealTimeClient.cookieSignedWith.sessionSecretUpcoming
|
||||
})
|
||||
it('should not return an error', function (done) {
|
||||
this.checkSocket(error => {
|
||||
expect(error).to.not.exist
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a cookie signed with an unrecognized secret and a matching session', function () {
|
||||
beforeEach(function () {
|
||||
const [sessionKey] = RealTimeClient.cookie.split('.')
|
||||
// sign the session key with a unrecognized secret
|
||||
RealTimeClient.cookie = signature.sign(
|
||||
sessionKey,
|
||||
'unrecognised-session-secret'
|
||||
)
|
||||
})
|
||||
it('should return a lookup error', function (done) {
|
||||
this.checkSocket(error => {
|
||||
expect(error).to.exist
|
||||
expect(error.message).to.equal('invalid session')
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
55
services/real-time/test/acceptance/js/SessionTests.js
Normal file
55
services/real-time/test/acceptance/js/SessionTests.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/* 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
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { expect } = require('chai')
|
||||
|
||||
const FixturesManager = require('./helpers/FixturesManager')
|
||||
const RealTimeClient = require('./helpers/RealTimeClient')
|
||||
|
||||
describe('Session', function () {
|
||||
return describe('with an established session', function () {
|
||||
before(function (done) {
|
||||
FixturesManager.setUpProject(
|
||||
{ privilegeLevel: 'owner' },
|
||||
(error, options) => {
|
||||
if (error) return done(error)
|
||||
this.client = RealTimeClient.connect(options.project_id, done)
|
||||
}
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
it('should not get disconnected', function (done) {
|
||||
let disconnected = false
|
||||
this.client.on('disconnect', () => (disconnected = true))
|
||||
return setTimeout(() => {
|
||||
expect(disconnected).to.equal(false)
|
||||
return done()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
return it('should appear in the list of connected clients', function (done) {
|
||||
return RealTimeClient.getConnectedClients((error, clients) => {
|
||||
if (error) return done(error)
|
||||
let included = false
|
||||
for (const client of Array.from(clients)) {
|
||||
if (client.client_id === this.client.socket.sessionid) {
|
||||
included = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(included).to.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
156
services/real-time/test/acceptance/js/helpers/FixturesManager.js
Normal file
156
services/real-time/test/acceptance/js/helpers/FixturesManager.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// 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
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
let FixturesManager
|
||||
const RealTimeClient = require('./RealTimeClient')
|
||||
const MockWebServer = require('./MockWebServer')
|
||||
const MockDocUpdaterServer = require('./MockDocUpdaterServer')
|
||||
|
||||
module.exports = FixturesManager = {
|
||||
setUpProject(options, callback) {
|
||||
if (options == null) {
|
||||
options = {}
|
||||
}
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
if (!options.user_id) {
|
||||
options.user_id = FixturesManager.getRandomId()
|
||||
}
|
||||
if (!options.project_id) {
|
||||
options.project_id = FixturesManager.getRandomId()
|
||||
}
|
||||
if (!options.project) {
|
||||
options.project = { name: 'Test Project' }
|
||||
}
|
||||
let {
|
||||
project_id: projectId,
|
||||
user_id: userId,
|
||||
privilegeLevel,
|
||||
project,
|
||||
publicAccess,
|
||||
userMetadata,
|
||||
anonymousAccessToken,
|
||||
} = options
|
||||
|
||||
if (privilegeLevel === 'owner') {
|
||||
project.owner = { _id: userId }
|
||||
} else {
|
||||
project.owner = { _id: '404404404404404404404404' }
|
||||
}
|
||||
|
||||
const privileges = {}
|
||||
privileges[userId] = privilegeLevel
|
||||
if (publicAccess) {
|
||||
anonymousAccessToken =
|
||||
anonymousAccessToken || FixturesManager.getRandomId()
|
||||
privileges[anonymousAccessToken] = publicAccess
|
||||
}
|
||||
|
||||
const metadataByUser = {}
|
||||
metadataByUser[userId] = userMetadata
|
||||
|
||||
MockWebServer.createMockProject(
|
||||
projectId,
|
||||
privileges,
|
||||
project,
|
||||
metadataByUser
|
||||
)
|
||||
return MockWebServer.run(error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return RealTimeClient.setSession(
|
||||
{
|
||||
user: {
|
||||
_id: userId,
|
||||
first_name: 'Joe',
|
||||
last_name: 'Bloggs',
|
||||
},
|
||||
},
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return callback(null, {
|
||||
project_id: projectId,
|
||||
user_id: userId,
|
||||
privilegeLevel,
|
||||
project,
|
||||
anonymousAccessToken,
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
setUpDoc(projectId, options, callback) {
|
||||
if (options == null) {
|
||||
options = {}
|
||||
}
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
if (!options.doc_id) {
|
||||
options.doc_id = FixturesManager.getRandomId()
|
||||
}
|
||||
if (!options.lines) {
|
||||
options.lines = ['doc', 'lines']
|
||||
}
|
||||
if (!options.version) {
|
||||
options.version = 42
|
||||
}
|
||||
if (!options.ops) {
|
||||
options.ops = ['mock', 'ops']
|
||||
}
|
||||
const { doc_id: docId, lines, version, ops, ranges } = options
|
||||
|
||||
MockDocUpdaterServer.createMockDoc(projectId, docId, {
|
||||
lines,
|
||||
version,
|
||||
ops,
|
||||
ranges,
|
||||
})
|
||||
return MockDocUpdaterServer.run(error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
return callback(null, {
|
||||
project_id: projectId,
|
||||
doc_id: docId,
|
||||
lines,
|
||||
version,
|
||||
ops,
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
setUpEditorSession(options, callback) {
|
||||
FixturesManager.setUpProject(options, (err, detailsProject) => {
|
||||
if (err) return callback(err)
|
||||
|
||||
FixturesManager.setUpDoc(
|
||||
detailsProject.project_id,
|
||||
options,
|
||||
(err, detailsDoc) => {
|
||||
if (err) return callback(err)
|
||||
|
||||
callback(null, Object.assign({}, detailsProject, detailsDoc))
|
||||
}
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
getRandomId() {
|
||||
return require('node:crypto')
|
||||
.createHash('sha1')
|
||||
.update(Math.random().toString())
|
||||
.digest('hex')
|
||||
.slice(0, 24)
|
||||
},
|
||||
}
|
@@ -0,0 +1,91 @@
|
||||
/* 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
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
let MockDocUpdaterServer
|
||||
const sinon = require('sinon')
|
||||
const express = require('express')
|
||||
|
||||
module.exports = MockDocUpdaterServer = {
|
||||
docs: {},
|
||||
|
||||
createMockDoc(projectId, docId, data) {
|
||||
return (MockDocUpdaterServer.docs[`${projectId}:${docId}`] = data)
|
||||
},
|
||||
|
||||
getDocument(projectId, docId, fromVersion, callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
return callback(null, MockDocUpdaterServer.docs[`${projectId}:${docId}`])
|
||||
},
|
||||
|
||||
deleteProject: sinon.stub().callsArg(1),
|
||||
|
||||
getDocumentRequest(req, res, next) {
|
||||
const { project_id: projectId, doc_id: docId } = req.params
|
||||
let { fromVersion } = req.query
|
||||
fromVersion = parseInt(fromVersion, 10)
|
||||
return MockDocUpdaterServer.getDocument(
|
||||
projectId,
|
||||
docId,
|
||||
fromVersion,
|
||||
(error, data) => {
|
||||
if (error != null) {
|
||||
return next(error)
|
||||
}
|
||||
if (!data) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
return res.json(data)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
deleteProjectRequest(req, res, next) {
|
||||
const { project_id: projectId } = req.params
|
||||
return MockDocUpdaterServer.deleteProject(projectId, error => {
|
||||
if (error != null) {
|
||||
return next(error)
|
||||
}
|
||||
return res.sendStatus(204)
|
||||
})
|
||||
},
|
||||
|
||||
running: false,
|
||||
run(callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
if (MockDocUpdaterServer.running) {
|
||||
return callback()
|
||||
}
|
||||
const app = express()
|
||||
app.get(
|
||||
'/project/:project_id/doc/:doc_id',
|
||||
MockDocUpdaterServer.getDocumentRequest
|
||||
)
|
||||
app.delete(
|
||||
'/project/:project_id',
|
||||
MockDocUpdaterServer.deleteProjectRequest
|
||||
)
|
||||
return app
|
||||
.listen(3003, error => {
|
||||
MockDocUpdaterServer.running = true
|
||||
return callback(error)
|
||||
})
|
||||
.on('error', error => {
|
||||
console.error('error starting MockDocUpdaterServer:', error.message)
|
||||
return process.exit(1)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
sinon.spy(MockDocUpdaterServer, 'getDocument')
|
106
services/real-time/test/acceptance/js/helpers/MockWebServer.js
Normal file
106
services/real-time/test/acceptance/js/helpers/MockWebServer.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/* 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
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
let MockWebServer
|
||||
const sinon = require('sinon')
|
||||
const express = require('express')
|
||||
const bodyParser = require('body-parser')
|
||||
|
||||
module.exports = MockWebServer = {
|
||||
projects: {},
|
||||
privileges: {},
|
||||
userMetadata: {},
|
||||
|
||||
createMockProject(projectId, privileges, project, metadataByUser) {
|
||||
MockWebServer.privileges[projectId] = privileges
|
||||
MockWebServer.userMetadata[projectId] = metadataByUser
|
||||
return (MockWebServer.projects[projectId] = project)
|
||||
},
|
||||
|
||||
inviteUserToProject(projectId, user, privileges) {
|
||||
MockWebServer.privileges[projectId][user._id] = privileges
|
||||
MockWebServer.userMetadata[projectId][user._id] = user
|
||||
},
|
||||
|
||||
joinProject(projectId, userId, anonymousAccessToken, callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
const project = MockWebServer.projects[projectId]
|
||||
const privilegeLevel =
|
||||
MockWebServer.privileges[projectId]?.[userId] ||
|
||||
MockWebServer.privileges[projectId]?.[anonymousAccessToken]
|
||||
const userMetadata = MockWebServer.userMetadata[projectId]?.[userId]
|
||||
return callback(null, project, privilegeLevel, userMetadata)
|
||||
},
|
||||
|
||||
joinProjectRequest(req, res, next) {
|
||||
const { project_id: projectId } = req.params
|
||||
const { anonymousAccessToken, userId } = req.body
|
||||
if (projectId === '404404404404404404404404') {
|
||||
// not-found
|
||||
return res.status(404).send()
|
||||
}
|
||||
if (projectId === '403403403403403403403403') {
|
||||
// forbidden
|
||||
return res.status(403).send()
|
||||
}
|
||||
if (projectId === '429429429429429429429429') {
|
||||
// rate-limited
|
||||
return res.status(429).send()
|
||||
} else {
|
||||
return MockWebServer.joinProject(
|
||||
projectId,
|
||||
userId,
|
||||
anonymousAccessToken,
|
||||
(error, project, privilegeLevel, userMetadata) => {
|
||||
if (error != null) {
|
||||
return next(error)
|
||||
}
|
||||
if (!project) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
return res.json({
|
||||
project,
|
||||
privilegeLevel,
|
||||
isRestrictedUser: !!userMetadata?.isRestrictedUser,
|
||||
isTokenMember: !!userMetadata?.isTokenMember,
|
||||
isInvitedMember: !!userMetadata?.isInvitedMember,
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
running: false,
|
||||
run(callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
if (MockWebServer.running) {
|
||||
return callback()
|
||||
}
|
||||
const app = express()
|
||||
app.use(bodyParser.json())
|
||||
app.post('/project/:project_id/join', MockWebServer.joinProjectRequest)
|
||||
return app
|
||||
.listen(3000, error => {
|
||||
MockWebServer.running = true
|
||||
return callback(error)
|
||||
})
|
||||
.on('error', error => {
|
||||
console.error('error starting MockWebServer:', error.message)
|
||||
return process.exit(1)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
sinon.spy(MockWebServer, 'joinProject')
|
165
services/real-time/test/acceptance/js/helpers/RealTimeClient.js
Normal file
165
services/real-time/test/acceptance/js/helpers/RealTimeClient.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/* 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
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
let Client
|
||||
const { XMLHttpRequest } = require('../../libs/XMLHttpRequest')
|
||||
const io = require('socket.io-client')
|
||||
const async = require('async')
|
||||
|
||||
const request = require('request')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const redis = require('@overleaf/redis-wrapper')
|
||||
const rclient = redis.createClient(Settings.redis.websessions)
|
||||
|
||||
const uid = require('uid-safe').sync
|
||||
const signature = require('cookie-signature')
|
||||
|
||||
io.util.request = function () {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const _open = xhr.open
|
||||
xhr.open = function () {
|
||||
_open.apply(xhr, arguments)
|
||||
if (Client.cookie != null) {
|
||||
return xhr.setRequestHeader('Cookie', Client.cookie)
|
||||
}
|
||||
}
|
||||
return xhr
|
||||
}
|
||||
|
||||
module.exports = Client = {
|
||||
cookie: null,
|
||||
|
||||
setSession(session, callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
const sessionId = uid(24)
|
||||
session.cookie = {}
|
||||
return rclient.set('sess:' + sessionId, JSON.stringify(session), error => {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
Client.cookieSignedWith = {}
|
||||
// prepare cookie strings for all supported session secrets
|
||||
for (const secretName of [
|
||||
'sessionSecret',
|
||||
'sessionSecretFallback',
|
||||
'sessionSecretUpcoming',
|
||||
]) {
|
||||
const secret = Settings.security[secretName]
|
||||
const cookieKey = 's:' + signature.sign(sessionId, secret)
|
||||
Client.cookieSignedWith[secretName] =
|
||||
`${Settings.cookieName}=${cookieKey}`
|
||||
}
|
||||
// default to the current session secret
|
||||
Client.cookie = Client.cookieSignedWith.sessionSecret
|
||||
return callback()
|
||||
})
|
||||
},
|
||||
|
||||
setAnonSession(projectId, anonymousAccessToken, callback) {
|
||||
Client.setSession(
|
||||
{
|
||||
anonTokenAccess: {
|
||||
[projectId]: anonymousAccessToken,
|
||||
},
|
||||
},
|
||||
callback
|
||||
)
|
||||
},
|
||||
|
||||
unsetSession(callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
Client.cookie = null
|
||||
return callback()
|
||||
},
|
||||
|
||||
connect(projectId, callback) {
|
||||
const client = io.connect('http://127.0.0.1:3026', {
|
||||
'force new connection': true,
|
||||
query: new URLSearchParams({ projectId }).toString(),
|
||||
})
|
||||
let disconnected = false
|
||||
client.on('disconnect', () => {
|
||||
disconnected = true
|
||||
})
|
||||
client.on('connectionRejected', err => {
|
||||
// Wait for disconnect ahead of continuing with the test sequence.
|
||||
setTimeout(() => {
|
||||
if (!disconnected) {
|
||||
throw new Error('should disconnect after connectionRejected')
|
||||
}
|
||||
callback(err)
|
||||
}, 10)
|
||||
})
|
||||
client.on('joinProjectResponse', resp => {
|
||||
const { publicId, project, permissionsLevel, protocolVersion } = resp
|
||||
client.publicId = publicId
|
||||
callback(null, project, permissionsLevel, protocolVersion)
|
||||
})
|
||||
return client
|
||||
},
|
||||
|
||||
getConnectedClients(callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
return request.get(
|
||||
{
|
||||
url: 'http://127.0.0.1:3026/clients',
|
||||
json: true,
|
||||
},
|
||||
(error, response, data) => callback(error, data)
|
||||
)
|
||||
},
|
||||
|
||||
getConnectedClient(clientId, callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
return request.get(
|
||||
{
|
||||
url: `http://127.0.0.1:3026/clients/${clientId}`,
|
||||
json: true,
|
||||
},
|
||||
(error, response, data) => {
|
||||
if (response?.statusCode === 404) {
|
||||
callback(new Error('not found'))
|
||||
} else {
|
||||
callback(error, data)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
disconnectClient(clientId, callback) {
|
||||
request.post(
|
||||
{
|
||||
url: `http://127.0.0.1:3026/client/${clientId}/disconnect`,
|
||||
},
|
||||
(error, response, data) => callback(error, data)
|
||||
)
|
||||
return null
|
||||
},
|
||||
|
||||
disconnectAllClients(callback) {
|
||||
return Client.getConnectedClients((error, clients) => {
|
||||
if (error) return callback(error)
|
||||
async.each(
|
||||
clients,
|
||||
(clientView, cb) => Client.disconnectClient(clientView.client_id, cb),
|
||||
callback
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
@@ -0,0 +1,61 @@
|
||||
// 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
|
||||
* DS103: Rewrite code to no longer use __guard__
|
||||
* DS205: Consider reworking code to avoid use of IIFEs
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const app = require('../../../../app')
|
||||
const logger = require('@overleaf/logger')
|
||||
const Settings = require('@overleaf/settings')
|
||||
|
||||
module.exports = {
|
||||
running: false,
|
||||
initing: false,
|
||||
callbacks: [],
|
||||
ensureRunning(callback) {
|
||||
if (callback == null) {
|
||||
callback = function () {}
|
||||
}
|
||||
if (this.running) {
|
||||
return callback()
|
||||
} else if (this.initing) {
|
||||
return this.callbacks.push(callback)
|
||||
} else {
|
||||
this.initing = true
|
||||
this.callbacks.push(callback)
|
||||
return app.listen(
|
||||
__guard__(
|
||||
Settings.internal != null ? Settings.internal.realtime : undefined,
|
||||
x => x.port
|
||||
),
|
||||
'127.0.0.1',
|
||||
error => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
this.running = true
|
||||
logger.info('clsi running in dev mode')
|
||||
|
||||
return (() => {
|
||||
const result = []
|
||||
for (callback of Array.from(this.callbacks)) {
|
||||
result.push(callback())
|
||||
}
|
||||
return result
|
||||
})()
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return typeof value !== 'undefined' && value !== null
|
||||
? transform(value)
|
||||
: undefined
|
||||
}
|
579
services/real-time/test/acceptance/libs/XMLHttpRequest.js
Normal file
579
services/real-time/test/acceptance/libs/XMLHttpRequest.js
Normal file
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* Wrapper for built-in http.js to emulate the browser XMLHttpRequest object.
|
||||
*
|
||||
* This can be used with JS designed for browsers to improve reuse of code and
|
||||
* allow the use of existing libraries.
|
||||
*
|
||||
* Usage: include("XMLHttpRequest.js") and use XMLHttpRequest per W3C specs.
|
||||
*
|
||||
* @author Dan DeFelippi <dan@driverdan.com>
|
||||
* @contributor David Ellis <d.f.ellis@ieee.org>
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
const { URL } = require('node:url')
|
||||
const spawn = require('node:child_process').spawn
|
||||
const fs = require('node:fs')
|
||||
|
||||
exports.XMLHttpRequest = function () {
|
||||
/**
|
||||
* Private variables
|
||||
*/
|
||||
const self = this
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
|
||||
// Holds http.js objects
|
||||
let request
|
||||
let response
|
||||
|
||||
// Request settings
|
||||
let settings = {}
|
||||
|
||||
// Set some default headers
|
||||
const defaultHeaders = {
|
||||
'User-Agent': 'node-XMLHttpRequest',
|
||||
Accept: '*/*',
|
||||
}
|
||||
|
||||
let headers = defaultHeaders
|
||||
|
||||
// These headers are not user setable.
|
||||
// The following are allowed but banned in the spec:
|
||||
// * user-agent
|
||||
const forbiddenRequestHeaders = [
|
||||
'accept-charset',
|
||||
'accept-encoding',
|
||||
'access-control-request-headers',
|
||||
'access-control-request-method',
|
||||
'connection',
|
||||
'content-length',
|
||||
'content-transfer-encoding',
|
||||
// "cookie",
|
||||
'cookie2',
|
||||
'date',
|
||||
'expect',
|
||||
'host',
|
||||
'keep-alive',
|
||||
'origin',
|
||||
'referer',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
'via',
|
||||
]
|
||||
|
||||
// These request methods are not allowed
|
||||
const forbiddenRequestMethods = ['TRACE', 'TRACK', 'CONNECT']
|
||||
|
||||
// Send flag
|
||||
let sendFlag = false
|
||||
// Error flag, used when errors occur or abort is called
|
||||
let errorFlag = false
|
||||
|
||||
// Event listeners
|
||||
const listeners = {}
|
||||
|
||||
/**
|
||||
* Constants
|
||||
*/
|
||||
|
||||
this.UNSENT = 0
|
||||
this.OPENED = 1
|
||||
this.HEADERS_RECEIVED = 2
|
||||
this.LOADING = 3
|
||||
this.DONE = 4
|
||||
|
||||
/**
|
||||
* Public vars
|
||||
*/
|
||||
|
||||
// Current state
|
||||
this.readyState = this.UNSENT
|
||||
|
||||
// default ready state change handler in case one is not set or is set late
|
||||
this.onreadystatechange = null
|
||||
|
||||
// Result & response
|
||||
this.responseText = ''
|
||||
this.responseXML = ''
|
||||
this.status = null
|
||||
this.statusText = null
|
||||
|
||||
/**
|
||||
* Private methods
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if the specified header is allowed.
|
||||
*
|
||||
* @param string header Header to validate
|
||||
* @return boolean False if not allowed, otherwise true
|
||||
*/
|
||||
const isAllowedHttpHeader = function (header) {
|
||||
return (
|
||||
header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the specified method is allowed.
|
||||
*
|
||||
* @param string method Request method to validate
|
||||
* @return boolean False if not allowed, otherwise true
|
||||
*/
|
||||
const isAllowedHttpMethod = function (method) {
|
||||
return method && forbiddenRequestMethods.indexOf(method) === -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Public methods
|
||||
*/
|
||||
|
||||
/**
|
||||
* Open the connection. Currently supports local server requests.
|
||||
*
|
||||
* @param string method Connection method (eg GET, POST)
|
||||
* @param string url URL for the connection.
|
||||
* @param boolean async Asynchronous connection. Default is true.
|
||||
* @param string user Username for basic authentication (optional)
|
||||
* @param string password Password for basic authentication (optional)
|
||||
*/
|
||||
this.open = function (method, url, async, user, password) {
|
||||
this.abort()
|
||||
errorFlag = false
|
||||
|
||||
// Check for valid request method
|
||||
if (!isAllowedHttpMethod(method)) {
|
||||
throw new Error('SecurityError: Request method not allowed')
|
||||
}
|
||||
|
||||
settings = {
|
||||
method,
|
||||
url: url.toString(),
|
||||
async: typeof async !== 'boolean' ? true : async,
|
||||
user: user || null,
|
||||
password: password || null,
|
||||
}
|
||||
|
||||
setState(this.OPENED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a header for the request.
|
||||
*
|
||||
* @param string header Header name
|
||||
* @param string value Header value
|
||||
*/
|
||||
this.setRequestHeader = function (header, value) {
|
||||
if (this.readyState !== this.OPENED) {
|
||||
throw new Error(
|
||||
'INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN'
|
||||
)
|
||||
}
|
||||
if (!isAllowedHttpHeader(header)) {
|
||||
console.warn('Refused to set unsafe header "' + header + '"')
|
||||
return
|
||||
}
|
||||
if (sendFlag) {
|
||||
throw new Error('INVALID_STATE_ERR: send flag is true')
|
||||
}
|
||||
headers[header] = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a header from the server response.
|
||||
*
|
||||
* @param string header Name of header to get.
|
||||
* @return string Text of the header or null if it doesn't exist.
|
||||
*/
|
||||
this.getResponseHeader = function (header) {
|
||||
if (
|
||||
typeof header === 'string' &&
|
||||
this.readyState > this.OPENED &&
|
||||
response.headers[header.toLowerCase()] &&
|
||||
!errorFlag
|
||||
) {
|
||||
return response.headers[header.toLowerCase()]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all the response headers.
|
||||
*
|
||||
* @return string A string with all response headers separated by CR+LF
|
||||
*/
|
||||
this.getAllResponseHeaders = function () {
|
||||
if (this.readyState < this.HEADERS_RECEIVED || errorFlag) {
|
||||
return ''
|
||||
}
|
||||
let result = ''
|
||||
|
||||
for (const i in response.headers) {
|
||||
// Cookie headers are excluded
|
||||
if (i !== 'set-cookie' && i !== 'set-cookie2') {
|
||||
result += i + ': ' + response.headers[i] + '\r\n'
|
||||
}
|
||||
}
|
||||
return result.substr(0, result.length - 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a request header
|
||||
*
|
||||
* @param string name Name of header to get
|
||||
* @return string Returns the request header or empty string if not set
|
||||
*/
|
||||
this.getRequestHeader = function (name) {
|
||||
// @TODO Make this case insensitive
|
||||
if (typeof name === 'string' && headers[name]) {
|
||||
return headers[name]
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the request to the server.
|
||||
*
|
||||
* @param string data Optional data to send as request body.
|
||||
*/
|
||||
this.send = function (data) {
|
||||
if (this.readyState !== this.OPENED) {
|
||||
throw new Error(
|
||||
'INVALID_STATE_ERR: connection must be opened before send() is called'
|
||||
)
|
||||
}
|
||||
|
||||
if (sendFlag) {
|
||||
throw new Error('INVALID_STATE_ERR: send has already been called')
|
||||
}
|
||||
|
||||
let host
|
||||
let ssl = false
|
||||
let local = false
|
||||
const url = new URL(settings.url)
|
||||
|
||||
// Determine the server
|
||||
switch (url.protocol) {
|
||||
case 'https:':
|
||||
ssl = true
|
||||
host = url.hostname
|
||||
break
|
||||
case 'http:':
|
||||
host = url.hostname
|
||||
break
|
||||
|
||||
case 'file:':
|
||||
local = true
|
||||
break
|
||||
|
||||
case undefined:
|
||||
case '':
|
||||
host = '127.0.0.1'
|
||||
break
|
||||
|
||||
default:
|
||||
throw new Error('Protocol not supported.')
|
||||
}
|
||||
|
||||
// Load files off the local filesystem (file://)
|
||||
if (local) {
|
||||
if (settings.method !== 'GET') {
|
||||
throw new Error('XMLHttpRequest: Only GET method is supported')
|
||||
}
|
||||
|
||||
if (settings.async) {
|
||||
fs.readFile(url.pathname, 'utf8', (error, data) => {
|
||||
if (error) {
|
||||
self.handleError(error)
|
||||
} else {
|
||||
self.status = 200
|
||||
self.responseText = data
|
||||
setState(self.DONE)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
try {
|
||||
this.responseText = fs.readFileSync(url.pathname, 'utf8')
|
||||
this.status = 200
|
||||
setState(self.DONE)
|
||||
} catch (e) {
|
||||
this.handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Default to port 80. If accessing 127.0.0.1 on another port be sure
|
||||
// to use http://127.0.0.1:port/path
|
||||
const port = url.port || (ssl ? 443 : 80)
|
||||
// Add query string if one is used
|
||||
const uri = url.pathname + (url.search ? url.search : '')
|
||||
|
||||
// Set the Host header or the server may reject the request
|
||||
headers.Host = host
|
||||
if (!((ssl && port === 443) || port === 80)) {
|
||||
headers.Host += ':' + url.port
|
||||
}
|
||||
|
||||
// Set Basic Auth if necessary
|
||||
if (settings.user) {
|
||||
if (typeof settings.password === 'undefined') {
|
||||
settings.password = ''
|
||||
}
|
||||
const authBuf = Buffer.from(settings.user + ':' + settings.password)
|
||||
headers.Authorization = 'Basic ' + authBuf.toString('base64')
|
||||
}
|
||||
|
||||
// Set content length header
|
||||
if (settings.method === 'GET' || settings.method === 'HEAD') {
|
||||
data = null
|
||||
} else if (data) {
|
||||
headers['Content-Length'] = Buffer.byteLength(data)
|
||||
|
||||
if (!headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'text/plain;charset=UTF-8'
|
||||
}
|
||||
} else if (settings.method === 'POST') {
|
||||
// For a post with no data set Content-Length: 0.
|
||||
// This is required by buggy servers that don't meet the specs.
|
||||
headers['Content-Length'] = 0
|
||||
}
|
||||
|
||||
const options = {
|
||||
host,
|
||||
port,
|
||||
path: uri,
|
||||
method: settings.method,
|
||||
headers,
|
||||
}
|
||||
|
||||
// Reset error flag
|
||||
errorFlag = false
|
||||
|
||||
// Handle async requests
|
||||
if (settings.async) {
|
||||
// Use the proper protocol
|
||||
const doRequest = ssl ? https.request : http.request
|
||||
|
||||
// Request is being sent, set send flag
|
||||
sendFlag = true
|
||||
|
||||
// As per spec, this is called here for historical reasons.
|
||||
self.dispatchEvent('readystatechange')
|
||||
|
||||
// Create the request
|
||||
request = doRequest(options, resp => {
|
||||
response = resp
|
||||
response.setEncoding('utf8')
|
||||
|
||||
setState(self.HEADERS_RECEIVED)
|
||||
self.status = response.statusCode
|
||||
|
||||
response.on('data', chunk => {
|
||||
// Make sure there's some data
|
||||
if (chunk) {
|
||||
self.responseText += chunk
|
||||
}
|
||||
// Don't emit state changes if the connection has been aborted.
|
||||
if (sendFlag) {
|
||||
setState(self.LOADING)
|
||||
}
|
||||
})
|
||||
|
||||
response.on('end', () => {
|
||||
if (sendFlag) {
|
||||
// Discard the 'end' event if the connection has been aborted
|
||||
setState(self.DONE)
|
||||
sendFlag = false
|
||||
}
|
||||
})
|
||||
|
||||
response.on('error', error => {
|
||||
self.handleError(error)
|
||||
})
|
||||
}).on('error', error => {
|
||||
self.handleError(error)
|
||||
})
|
||||
|
||||
// Node 0.4 and later won't accept empty data. Make sure it's needed.
|
||||
if (data) {
|
||||
request.write(data)
|
||||
}
|
||||
|
||||
request.end()
|
||||
|
||||
self.dispatchEvent('loadstart')
|
||||
} else {
|
||||
// Synchronous
|
||||
// Create a temporary file for communication with the other Node process
|
||||
const syncFile = '.node-xmlhttprequest-sync-' + process.pid
|
||||
fs.writeFileSync(syncFile, '', 'utf8')
|
||||
// The async request the other Node process executes
|
||||
const execString =
|
||||
"var http = require('http'), https = require('https'), fs = require('fs');" +
|
||||
'var doRequest = http' +
|
||||
(ssl ? 's' : '') +
|
||||
'.request;' +
|
||||
'var options = ' +
|
||||
JSON.stringify(options) +
|
||||
';' +
|
||||
"var responseText = '';" +
|
||||
'var req = doRequest(options, function(response) {' +
|
||||
"response.setEncoding('utf8');" +
|
||||
"response.on('data', function(chunk) {" +
|
||||
'responseText += chunk;' +
|
||||
'});' +
|
||||
"response.on('end', function() {" +
|
||||
"fs.writeFileSync('" +
|
||||
syncFile +
|
||||
"', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8');" +
|
||||
'});' +
|
||||
"response.on('error', function(error) {" +
|
||||
"fs.writeFileSync('" +
|
||||
syncFile +
|
||||
"', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" +
|
||||
'});' +
|
||||
"}).on('error', function(error) {" +
|
||||
"fs.writeFileSync('" +
|
||||
syncFile +
|
||||
"', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" +
|
||||
'});' +
|
||||
(data ? "req.write('" + data.replace(/'/g, "\\'") + "');" : '') +
|
||||
'req.end();'
|
||||
// Start the other Node Process, executing this string
|
||||
const syncProc = spawn(process.argv[0], ['-e', execString])
|
||||
while ((self.responseText = fs.readFileSync(syncFile, 'utf8')) === '') {
|
||||
// Wait while the file is empty
|
||||
}
|
||||
// Kill the child process once the file has data
|
||||
syncProc.stdin.end()
|
||||
// Remove the temporary file
|
||||
fs.unlinkSync(syncFile)
|
||||
if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) {
|
||||
// If the file returned an error, handle it
|
||||
const errorObj = self.responseText.replace(
|
||||
/^NODE-XMLHTTPREQUEST-ERROR:/,
|
||||
''
|
||||
)
|
||||
self.handleError(errorObj)
|
||||
} else {
|
||||
// If the file returned okay, parse its data and move to the DONE state
|
||||
self.status = self.responseText.replace(
|
||||
/^NODE-XMLHTTPREQUEST-STATUS:([0-9]*),.*/,
|
||||
'$1'
|
||||
)
|
||||
self.responseText = self.responseText.replace(
|
||||
/^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/,
|
||||
'$1'
|
||||
)
|
||||
setState(self.DONE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an error is encountered to deal with it.
|
||||
*/
|
||||
this.handleError = function (error) {
|
||||
this.status = 503
|
||||
this.statusText = error
|
||||
this.responseText = error.stack
|
||||
errorFlag = true
|
||||
setState(this.DONE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts a request.
|
||||
*/
|
||||
this.abort = function () {
|
||||
if (request) {
|
||||
request.abort()
|
||||
request = null
|
||||
}
|
||||
|
||||
headers = defaultHeaders
|
||||
this.responseText = ''
|
||||
this.responseXML = ''
|
||||
|
||||
errorFlag = true
|
||||
|
||||
if (
|
||||
this.readyState !== this.UNSENT &&
|
||||
(this.readyState !== this.OPENED || sendFlag) &&
|
||||
this.readyState !== this.DONE
|
||||
) {
|
||||
sendFlag = false
|
||||
setState(this.DONE)
|
||||
}
|
||||
this.readyState = this.UNSENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener. Preferred method of binding to events.
|
||||
*/
|
||||
this.addEventListener = function (event, callback) {
|
||||
if (!(event in listeners)) {
|
||||
listeners[event] = []
|
||||
}
|
||||
// Currently allows duplicate callbacks. Should it?
|
||||
listeners[event].push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event callback that has already been bound.
|
||||
* Only works on the matching funciton, cannot be a copy.
|
||||
*/
|
||||
this.removeEventListener = function (event, callback) {
|
||||
if (event in listeners) {
|
||||
// Filter will return a new array with the callback removed
|
||||
listeners[event] = listeners[event].filter(ev => {
|
||||
return ev !== callback
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch any events, including both "on" methods and events attached using addEventListener.
|
||||
*/
|
||||
this.dispatchEvent = function (event) {
|
||||
if (typeof self['on' + event] === 'function') {
|
||||
self['on' + event]()
|
||||
}
|
||||
if (event in listeners) {
|
||||
for (let i = 0, len = listeners[event].length; i < len; i++) {
|
||||
listeners[event][i].call(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes readyState and calls onreadystatechange.
|
||||
*
|
||||
* @param int state New state
|
||||
*/
|
||||
function setState(state) {
|
||||
if (self.readyState !== state) {
|
||||
self.readyState = state
|
||||
|
||||
if (
|
||||
settings.async ||
|
||||
self.readyState < self.OPENED ||
|
||||
self.readyState === self.DONE
|
||||
) {
|
||||
self.dispatchEvent('readystatechange')
|
||||
}
|
||||
|
||||
if (self.readyState === self.DONE && !errorFlag) {
|
||||
self.dispatchEvent('load')
|
||||
// @TODO figure out InspectorInstrumentation::didLoadXHR(cookie)
|
||||
self.dispatchEvent('loadend')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
47
services/real-time/test/setup.js
Normal file
47
services/real-time/test/setup.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const chai = require('chai')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
const chaiAsPromised = require('chai-as-promised')
|
||||
const sinonChai = require('sinon-chai')
|
||||
|
||||
// Chai configuration
|
||||
chai.should()
|
||||
chai.use(chaiAsPromised)
|
||||
chai.use(sinonChai)
|
||||
|
||||
// Global stubs
|
||||
const sandbox = sinon.createSandbox()
|
||||
const stubs = {
|
||||
logger: {
|
||||
debug: sandbox.stub(),
|
||||
log: sandbox.stub(),
|
||||
info: sandbox.stub(),
|
||||
warn: sandbox.stub(),
|
||||
err: sandbox.stub(),
|
||||
error: sandbox.stub(),
|
||||
},
|
||||
}
|
||||
|
||||
// SandboxedModule configuration
|
||||
SandboxedModule.configure({
|
||||
requires: {
|
||||
'@overleaf/logger': stubs.logger,
|
||||
},
|
||||
globals: { Buffer, JSON, console, process },
|
||||
sourceTransformers: {
|
||||
removeNodePrefix: function (source) {
|
||||
return source.replace(/require\(['"]node:/g, "require('")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mocha hooks
|
||||
exports.mochaHooks = {
|
||||
beforeEach() {
|
||||
this.logger = stubs.logger
|
||||
},
|
||||
|
||||
afterEach() {
|
||||
sandbox.reset()
|
||||
},
|
||||
}
|
428
services/real-time/test/unit/js/AuthorizationManagerTests.js
Normal file
428
services/real-time/test/unit/js/AuthorizationManagerTests.js
Normal 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')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
432
services/real-time/test/unit/js/ChannelManagerTests.js
Normal file
432
services/real-time/test/unit/js/ChannelManagerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
648
services/real-time/test/unit/js/ConnectedUsersManagerTests.js
Normal file
648
services/real-time/test/unit/js/ConnectedUsersManagerTests.js
Normal 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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
423
services/real-time/test/unit/js/DocumentUpdaterManagerTests.js
Normal file
423
services/real-time/test/unit/js/DocumentUpdaterManagerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
127
services/real-time/test/unit/js/DrainManagerTests.js
Normal file
127
services/real-time/test/unit/js/DrainManagerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
153
services/real-time/test/unit/js/EventLoggerTests.js
Normal file
153
services/real-time/test/unit/js/EventLoggerTests.js
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
412
services/real-time/test/unit/js/RoomManagerTests.js
Normal file
412
services/real-time/test/unit/js/RoomManagerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
55
services/real-time/test/unit/js/SafeJsonParseTest.js
Normal file
55
services/real-time/test/unit/js/SafeJsonParseTest.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
280
services/real-time/test/unit/js/SessionSocketsTests.js
Normal file
280
services/real-time/test/unit/js/SessionSocketsTests.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
268
services/real-time/test/unit/js/WebApiManagerTests.js
Normal file
268
services/real-time/test/unit/js/WebApiManagerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
100
services/real-time/test/unit/js/WebsocketAddressManagerTests.js
Normal file
100
services/real-time/test/unit/js/WebsocketAddressManagerTests.js
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
1698
services/real-time/test/unit/js/WebsocketControllerTests.js
Normal file
1698
services/real-time/test/unit/js/WebsocketControllerTests.js
Normal file
File diff suppressed because it is too large
Load Diff
514
services/real-time/test/unit/js/WebsocketLoadBalancerTests.js
Normal file
514
services/real-time/test/unit/js/WebsocketLoadBalancerTests.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
23
services/real-time/test/unit/js/helpers/MockClient.js
Normal file
23
services/real-time/test/unit/js/helpers/MockClient.js
Normal 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() {}
|
||||
}
|
Reference in New Issue
Block a user