first commit

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

View File

@@ -0,0 +1,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
})
})
})

View 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: '',
},
])
})
})
})

View 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)
})
})
})
})

View 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
})
)
})
})

View 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()
}
)
})
})
})

View 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()
}
)
})
})
})

View 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')
})
})
})
})

View 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'
)
})
})
})
})

View 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
})
})
})

View 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')
})
})
})
})
})
})
})
})
})

View 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
})
})
})

View 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 },
])
})
})
})

View 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)
})
})
})

View 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')
})
})
})
})

View 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()
})
})
})
})

View 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()
})
})
})
})

View 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)
},
}

View File

@@ -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')

View 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')

View 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
)
})
},
}

View File

@@ -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
}

View 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')
}
}
}
}