WebsocketController.test.js 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750
  1. import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
  2. /* eslint-disable
  3. no-return-assign,
  4. no-throw-literal,
  5. no-unused-vars,
  6. */
  7. // TODO: This file was created by bulk-decaffeinate.
  8. // Fix any style issues and re-enable lint.
  9. /*
  10. * decaffeinate suggestions:
  11. * DS101: Remove unnecessary use of Array.from
  12. * DS102: Remove unnecessary code created because of implicit returns
  13. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  14. */
  15. import sinon from 'sinon'
  16. import tk from 'timekeeper'
  17. import Errors from '../../../app/js/Errors.js'
  18. const modulePath = '../../../app/js/WebsocketController.js'
  19. const { UpdateTooLargeError } = Errors
  20. describe('WebsocketController', function () {
  21. beforeEach(async function (ctx) {
  22. tk.freeze(new Date())
  23. ctx.project_id = 'project-id-123'
  24. ctx.user = {
  25. _id: (ctx.user_id = 'user-id-123'),
  26. first_name: 'James',
  27. last_name: 'Allen',
  28. email: 'james@example.com',
  29. signUpDate: new Date('2014-01-01'),
  30. loginCount: 42,
  31. }
  32. ctx.callback = sinon.stub()
  33. ctx.client = {
  34. disconnected: false,
  35. id: (ctx.client_id = 'mock-client-id-123'),
  36. publicId: `other-id-${Math.random()}`,
  37. ol_context: {},
  38. joinLeaveEpoch: 0,
  39. join: sinon.stub(),
  40. leave: sinon.stub(),
  41. }
  42. vi.doMock('../../../app/js/WebApiManager', () => ({
  43. default: (ctx.WebApiManager = {}),
  44. }))
  45. vi.doMock('../../../app/js/AuthorizationManager', () => ({
  46. default: (ctx.AuthorizationManager = {}),
  47. }))
  48. vi.doMock('../../../app/js/DocumentUpdaterManager', () => ({
  49. default: (ctx.DocumentUpdaterManager = {}),
  50. }))
  51. vi.doMock('../../../app/js/ConnectedUsersManager', () => ({
  52. default: (ctx.ConnectedUsersManager = {}),
  53. }))
  54. vi.doMock('../../../app/js/WebsocketLoadBalancer', () => ({
  55. default: (ctx.WebsocketLoadBalancer = {}),
  56. }))
  57. vi.doMock('@overleaf/metrics', () => ({
  58. default: (ctx.metrics = {
  59. inc: sinon.stub(),
  60. set: sinon.stub(),
  61. }),
  62. }))
  63. vi.doMock('../../../app/js/RoomManager', () => ({
  64. default: (ctx.RoomManager = {}),
  65. }))
  66. ctx.WebsocketController = (await import(modulePath)).default
  67. })
  68. afterEach(function () {
  69. return tk.reset()
  70. })
  71. describe('joinProject', function () {
  72. describe('when authorised', function () {
  73. beforeEach(function (ctx) {
  74. ctx.client.id = 'mock-client-id'
  75. ctx.project = {
  76. name: 'Test Project',
  77. owner: {
  78. _id: (ctx.owner_id = 'mock-owner-id-123'),
  79. },
  80. }
  81. ctx.privilegeLevel = 'owner'
  82. ctx.ConnectedUsersManager.updateUserPosition = sinon
  83. .stub()
  84. .callsArgAsync(4)
  85. ctx.isRestrictedUser = true
  86. ctx.isTokenMember = true
  87. ctx.isInvitedMember = true
  88. ctx.WebApiManager.joinProject = sinon
  89. .stub()
  90. .callsArgWith(2, null, ctx.project, ctx.privilegeLevel, {
  91. isRestrictedUser: ctx.isRestrictedUser,
  92. isTokenMember: ctx.isTokenMember,
  93. isInvitedMember: ctx.isInvitedMember,
  94. })
  95. ctx.RoomManager.joinProject = sinon.stub().callsArg(2)
  96. return ctx.WebsocketController.joinProject(
  97. ctx.client,
  98. ctx.user,
  99. ctx.project_id,
  100. ctx.callback
  101. )
  102. })
  103. it('should load the project from web', function (ctx) {
  104. return ctx.WebApiManager.joinProject
  105. .calledWith(ctx.project_id, ctx.user)
  106. .should.equal(true)
  107. })
  108. it('should join the project room', function (ctx) {
  109. return ctx.RoomManager.joinProject
  110. .calledWith(ctx.client, ctx.project_id)
  111. .should.equal(true)
  112. })
  113. it('should set the privilege level on the client', function (ctx) {
  114. return ctx.client.ol_context.privilege_level.should.equal(
  115. ctx.privilegeLevel
  116. )
  117. })
  118. it("should set the user's id on the client", function (ctx) {
  119. return ctx.client.ol_context.user_id.should.equal(ctx.user._id)
  120. })
  121. it("should set the user's email on the client", function (ctx) {
  122. return ctx.client.ol_context.email.should.equal(ctx.user.email)
  123. })
  124. it("should set the user's first_name on the client", function (ctx) {
  125. return ctx.client.ol_context.first_name.should.equal(
  126. ctx.user.first_name
  127. )
  128. })
  129. it("should set the user's last_name on the client", function (ctx) {
  130. return ctx.client.ol_context.last_name.should.equal(ctx.user.last_name)
  131. })
  132. it("should set the user's sign up date on the client", function (ctx) {
  133. return ctx.client.ol_context.signup_date.should.equal(
  134. ctx.user.signUpDate
  135. )
  136. })
  137. it("should set the user's login_count on the client", function (ctx) {
  138. return ctx.client.ol_context.login_count.should.equal(
  139. ctx.user.loginCount
  140. )
  141. })
  142. it('should set the connected time on the client', function (ctx) {
  143. return ctx.client.ol_context.connected_time.should.equal(new Date())
  144. })
  145. it('should set the project_id on the client', function (ctx) {
  146. return ctx.client.ol_context.project_id.should.equal(ctx.project_id)
  147. })
  148. it('should set the project owner id on the client', function (ctx) {
  149. return ctx.client.ol_context.owner_id.should.equal(ctx.owner_id)
  150. })
  151. it('should set the is_restricted_user flag on the client', function (ctx) {
  152. return ctx.client.ol_context.is_restricted_user.should.equal(
  153. ctx.isRestrictedUser
  154. )
  155. })
  156. it('should set the is_token_member flag on the client', function (ctx) {
  157. ctx.client.ol_context.is_token_member.should.equal(ctx.isTokenMember)
  158. })
  159. it('should set the is_invited_member flag on the client', function (ctx) {
  160. ctx.client.ol_context.is_invited_member.should.equal(
  161. ctx.isInvitedMember
  162. )
  163. })
  164. it('should call the callback with the project, privilegeLevel and protocolVersion', function (ctx) {
  165. return ctx.callback
  166. .calledWith(
  167. null,
  168. ctx.project,
  169. ctx.privilegeLevel,
  170. ctx.WebsocketController.PROTOCOL_VERSION
  171. )
  172. .should.equal(true)
  173. })
  174. it('should mark the user as connected in ConnectedUsersManager', function (ctx) {
  175. return ctx.ConnectedUsersManager.updateUserPosition
  176. .calledWith(ctx.project_id, ctx.client.publicId, ctx.user, null)
  177. .should.equal(true)
  178. })
  179. return it('should increment the join-project metric', function (ctx) {
  180. return ctx.metrics.inc
  181. .calledWith('editor.join-project')
  182. .should.equal(true)
  183. })
  184. })
  185. describe('when not authorized', function () {
  186. beforeEach(function (ctx) {
  187. ctx.WebApiManager.joinProject = sinon
  188. .stub()
  189. .callsArgWith(2, null, null, null)
  190. return ctx.WebsocketController.joinProject(
  191. ctx.client,
  192. ctx.user,
  193. ctx.project_id,
  194. ctx.callback
  195. )
  196. })
  197. it('should return an error', function (ctx) {
  198. return ctx.callback
  199. .calledWith(sinon.match({ message: 'not authorized' }))
  200. .should.equal(true)
  201. })
  202. return it('should not log an error', function (ctx) {
  203. return ctx.logger.error.called.should.equal(false)
  204. })
  205. })
  206. describe('when the subscribe failed', function () {
  207. beforeEach(function (ctx) {
  208. ctx.client.id = 'mock-client-id'
  209. ctx.project = {
  210. name: 'Test Project',
  211. owner: {
  212. _id: (ctx.owner_id = 'mock-owner-id-123'),
  213. },
  214. }
  215. ctx.privilegeLevel = 'owner'
  216. ctx.ConnectedUsersManager.updateUserPosition = sinon
  217. .stub()
  218. .callsArgAsync(4)
  219. ctx.isRestrictedUser = true
  220. ctx.isTokenMember = true
  221. ctx.isInvitedMember = true
  222. ctx.WebApiManager.joinProject = sinon
  223. .stub()
  224. .callsArgWith(2, null, ctx.project, ctx.privilegeLevel, {
  225. isRestrictedUser: ctx.isRestrictedUser,
  226. isTokenMember: ctx.isTokenMember,
  227. isInvitedMember: ctx.isInvitedMember,
  228. })
  229. ctx.RoomManager.joinProject = sinon
  230. .stub()
  231. .callsArgWith(2, new Error('subscribe failed'))
  232. return ctx.WebsocketController.joinProject(
  233. ctx.client,
  234. ctx.user,
  235. ctx.project_id,
  236. ctx.callback
  237. )
  238. })
  239. return it('should return an error', function (ctx) {
  240. ctx.callback
  241. .calledWith(sinon.match({ message: 'subscribe failed' }))
  242. .should.equal(true)
  243. return ctx.callback.args[0][0].message.should.equal('subscribe failed')
  244. })
  245. })
  246. describe('when the client has disconnected', function () {
  247. beforeEach(function (ctx) {
  248. ctx.client.disconnected = true
  249. ctx.WebApiManager.joinProject = sinon.stub().callsArg(2)
  250. return ctx.WebsocketController.joinProject(
  251. ctx.client,
  252. ctx.user,
  253. ctx.project_id,
  254. ctx.callback
  255. )
  256. })
  257. it('should not call WebApiManager.joinProject', function (ctx) {
  258. return expect(ctx.WebApiManager.joinProject.called).to.equal(false)
  259. })
  260. it('should call the callback with no details', function (ctx) {
  261. return expect(ctx.callback.args[0]).to.deep.equal([])
  262. })
  263. return it('should increment the editor.join-project.disconnected metric with a status', function (ctx) {
  264. return expect(
  265. ctx.metrics.inc.calledWith('editor.join-project.disconnected', 1, {
  266. status: 'immediately',
  267. })
  268. ).to.equal(true)
  269. })
  270. })
  271. return describe('when the client disconnects while WebApiManager.joinProject is running', function () {
  272. beforeEach(function (ctx) {
  273. ctx.WebApiManager.joinProject = (project, user, cb) => {
  274. ctx.client.disconnected = true
  275. return cb(null, ctx.project, ctx.privilegeLevel, {
  276. isRestrictedUser: ctx.isRestrictedUser,
  277. isTokenMember: ctx.isTokenMember,
  278. isInvitedMember: ctx.isInvitedMember,
  279. })
  280. }
  281. return ctx.WebsocketController.joinProject(
  282. ctx.client,
  283. ctx.user,
  284. ctx.project_id,
  285. ctx.callback
  286. )
  287. })
  288. it('should call the callback with no details', function (ctx) {
  289. return expect(ctx.callback.args[0]).to.deep.equal([])
  290. })
  291. return it('should increment the editor.join-project.disconnected metric with a status', function (ctx) {
  292. return expect(
  293. ctx.metrics.inc.calledWith('editor.join-project.disconnected', 1, {
  294. status: 'after-web-api-call',
  295. })
  296. ).to.equal(true)
  297. })
  298. })
  299. })
  300. describe('leaveProject', function () {
  301. beforeEach(function (ctx) {
  302. ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete = sinon
  303. .stub()
  304. .callsArg(1)
  305. ctx.ConnectedUsersManager.markUserAsDisconnected = sinon
  306. .stub()
  307. .callsArg(2)
  308. ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub()
  309. ctx.RoomManager.leaveProjectAndDocs = sinon.stub()
  310. ctx.clientsInRoom = []
  311. ctx.io = {
  312. sockets: {
  313. clients: roomId => {
  314. if (roomId !== ctx.project_id) {
  315. throw 'expected room_id to be project_id'
  316. }
  317. return ctx.clientsInRoom
  318. },
  319. },
  320. }
  321. ctx.client.ol_context.project_id = ctx.project_id
  322. ctx.client.ol_context.user_id = ctx.user_id
  323. ctx.WebsocketController.FLUSH_IF_EMPTY_DELAY = 0
  324. tk.reset()
  325. }) // Allow setTimeout to work.
  326. describe('when the client did not joined a project yet', function () {
  327. beforeEach(async function (ctx) {
  328. await new Promise((resolve, reject) => {
  329. ctx.client.ol_context = {}
  330. ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => {
  331. if (err) return reject(err)
  332. resolve()
  333. })
  334. })
  335. })
  336. it('should bail out when calling leaveProject', function (ctx) {
  337. ctx.WebsocketLoadBalancer.emitToRoom.called.should.equal(false)
  338. ctx.RoomManager.leaveProjectAndDocs.called.should.equal(false)
  339. ctx.ConnectedUsersManager.markUserAsDisconnected.called.should.equal(
  340. false
  341. )
  342. })
  343. it('should not inc any metric', function (ctx) {
  344. ctx.metrics.inc.called.should.equal(false)
  345. })
  346. })
  347. describe('when the project is empty', function () {
  348. beforeEach(async function (ctx) {
  349. await new Promise((resolve, reject) => {
  350. ctx.clientsInRoom = []
  351. ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => {
  352. if (err) return reject(err)
  353. resolve()
  354. })
  355. })
  356. })
  357. it('should end clientTracking.clientDisconnected to the project room', function (ctx) {
  358. ctx.WebsocketLoadBalancer.emitToRoom
  359. .calledWith(
  360. ctx.project_id,
  361. 'clientTracking.clientDisconnected',
  362. ctx.client.publicId
  363. )
  364. .should.equal(true)
  365. })
  366. it('should mark the user as disconnected', function (ctx) {
  367. ctx.ConnectedUsersManager.markUserAsDisconnected
  368. .calledWith(ctx.project_id, ctx.client.publicId)
  369. .should.equal(true)
  370. })
  371. it('should flush the project in the document updater', function (ctx) {
  372. ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete
  373. .calledWith(ctx.project_id)
  374. .should.equal(true)
  375. })
  376. it('should increment the leave-project metric', function (ctx) {
  377. ctx.metrics.inc.calledWith('editor.leave-project').should.equal(true)
  378. })
  379. it('should track the disconnection in RoomManager', function (ctx) {
  380. ctx.RoomManager.leaveProjectAndDocs
  381. .calledWith(ctx.client)
  382. .should.equal(true)
  383. })
  384. })
  385. describe('when the project is not empty', function () {
  386. beforeEach(async function (ctx) {
  387. await new Promise((resolve, reject) => {
  388. ctx.clientsInRoom = ['mock-remaining-client']
  389. ctx.io = {
  390. sockets: {
  391. clients: roomId => {
  392. if (roomId !== ctx.project_id) {
  393. throw 'expected room_id to be project_id'
  394. }
  395. return ctx.clientsInRoom
  396. },
  397. },
  398. }
  399. ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => {
  400. if (err) return reject(err)
  401. resolve()
  402. })
  403. })
  404. })
  405. it('should not flush the project in the document updater', function (ctx) {
  406. ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete.called.should.equal(
  407. false
  408. )
  409. })
  410. })
  411. describe('when client has not authenticated', function () {
  412. beforeEach(async function (ctx) {
  413. await new Promise((resolve, reject) => {
  414. ctx.client.ol_context.user_id = null
  415. ctx.client.ol_context.project_id = null
  416. ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => {
  417. if (err) return reject(err)
  418. resolve()
  419. })
  420. })
  421. })
  422. it('should not end clientTracking.clientDisconnected to the project room', function (ctx) {
  423. ctx.WebsocketLoadBalancer.emitToRoom
  424. .calledWith(
  425. ctx.project_id,
  426. 'clientTracking.clientDisconnected',
  427. ctx.client.publicId
  428. )
  429. .should.equal(false)
  430. })
  431. it('should not mark the user as disconnected', function (ctx) {
  432. ctx.ConnectedUsersManager.markUserAsDisconnected
  433. .calledWith(ctx.project_id, ctx.client.publicId)
  434. .should.equal(false)
  435. })
  436. it('should not flush the project in the document updater', function (ctx) {
  437. ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete
  438. .calledWith(ctx.project_id)
  439. .should.equal(false)
  440. })
  441. it('should not increment the leave-project metric', function (ctx) {
  442. ctx.metrics.inc.calledWith('editor.leave-project').should.equal(false)
  443. })
  444. })
  445. describe('when client has not joined a project', function () {
  446. beforeEach(async function (ctx) {
  447. await new Promise((resolve, reject) => {
  448. ctx.client.ol_context.user_id = ctx.user_id
  449. ctx.client.ol_context.project_id = null
  450. ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => {
  451. if (err) return reject(err)
  452. resolve()
  453. })
  454. })
  455. })
  456. it('should not end clientTracking.clientDisconnected to the project room', function (ctx) {
  457. ctx.WebsocketLoadBalancer.emitToRoom
  458. .calledWith(
  459. ctx.project_id,
  460. 'clientTracking.clientDisconnected',
  461. ctx.client.publicId
  462. )
  463. .should.equal(false)
  464. })
  465. it('should not mark the user as disconnected', function (ctx) {
  466. ctx.ConnectedUsersManager.markUserAsDisconnected
  467. .calledWith(ctx.project_id, ctx.client.publicId)
  468. .should.equal(false)
  469. })
  470. it('should not flush the project in the document updater', function (ctx) {
  471. ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete
  472. .calledWith(ctx.project_id)
  473. .should.equal(false)
  474. })
  475. it('should not increment the leave-project metric', function (ctx) {
  476. ctx.metrics.inc.calledWith('editor.leave-project').should.equal(false)
  477. })
  478. })
  479. })
  480. describe('joinDoc', function () {
  481. beforeEach(function (ctx) {
  482. ctx.doc_id = 'doc-id-123'
  483. ctx.doc_lines = ['doc', 'lines']
  484. ctx.version = 42
  485. ctx.ops = ['mock', 'ops']
  486. ctx.ranges = { mock: 'ranges' }
  487. ctx.options = {}
  488. ctx.client.ol_context.project_id = ctx.project_id
  489. ctx.client.ol_context.is_restricted_user = false
  490. ctx.AuthorizationManager.addAccessToDoc = sinon.stub().yields()
  491. ctx.AuthorizationManager.assertClientCanViewProject = sinon
  492. .stub()
  493. .callsArgWith(1, null)
  494. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon
  495. .stub()
  496. .callsArgWith(2, null)
  497. ctx.DocumentUpdaterManager.getDocument = sinon
  498. .stub()
  499. .callsArgWith(3, null, ctx.doc_lines, ctx.version, ctx.ranges, ctx.ops)
  500. ctx.RoomManager.joinDoc = sinon.stub().callsArg(2)
  501. })
  502. describe('works', function () {
  503. beforeEach(function (ctx) {
  504. ctx.WebsocketController.joinDoc(
  505. ctx.client,
  506. ctx.doc_id,
  507. -1,
  508. ctx.options,
  509. ctx.callback
  510. )
  511. })
  512. it('should inc the joinLeaveEpoch', function (ctx) {
  513. expect(ctx.client.joinLeaveEpoch).to.equal(1)
  514. })
  515. it('should check that the client is authorized to view the project', function (ctx) {
  516. ctx.AuthorizationManager.assertClientCanViewProject
  517. .calledWith(ctx.client)
  518. .should.equal(true)
  519. })
  520. it('should get the document from the DocumentUpdaterManager with fromVersion', function (ctx) {
  521. ctx.DocumentUpdaterManager.getDocument
  522. .calledWith(ctx.project_id, ctx.doc_id, -1)
  523. .should.equal(true)
  524. })
  525. it('should add permissions for the client to access the doc', function (ctx) {
  526. ctx.AuthorizationManager.addAccessToDoc
  527. .calledWith(ctx.client, ctx.doc_id)
  528. .should.equal(true)
  529. })
  530. it('should join the client to room for the doc_id', function (ctx) {
  531. ctx.RoomManager.joinDoc
  532. .calledWith(ctx.client, ctx.doc_id)
  533. .should.equal(true)
  534. })
  535. it('should call the callback with the lines, version, ranges and ops', function (ctx) {
  536. ctx.callback
  537. .calledWith(null, ctx.doc_lines, ctx.version, ctx.ops, ctx.ranges)
  538. .should.equal(true)
  539. })
  540. it('should increment the join-doc metric', function (ctx) {
  541. ctx.metrics.inc.calledWith('editor.join-doc').should.equal(true)
  542. })
  543. })
  544. describe('with a fromVersion', function () {
  545. beforeEach(function (ctx) {
  546. ctx.fromVersion = 40
  547. ctx.WebsocketController.joinDoc(
  548. ctx.client,
  549. ctx.doc_id,
  550. ctx.fromVersion,
  551. ctx.options,
  552. ctx.callback
  553. )
  554. })
  555. it('should get the document from the DocumentUpdaterManager with fromVersion', function (ctx) {
  556. ctx.DocumentUpdaterManager.getDocument
  557. .calledWith(ctx.project_id, ctx.doc_id, ctx.fromVersion)
  558. .should.equal(true)
  559. })
  560. })
  561. describe('with doclines that need escaping', function () {
  562. beforeEach(function (ctx) {
  563. ctx.doc_lines.push(['räksmörgås'])
  564. ctx.WebsocketController.joinDoc(
  565. ctx.client,
  566. ctx.doc_id,
  567. -1,
  568. ctx.options,
  569. ctx.callback
  570. )
  571. })
  572. it('should call the callback with the escaped lines', function (ctx) {
  573. const escapedLines = ctx.callback.args[0][1]
  574. const escapedWord = escapedLines.pop()
  575. escapedWord.should.equal('räksmörgås')
  576. // Check that unescaping works
  577. decodeURIComponent(escape(escapedWord)).should.equal('räksmörgås')
  578. })
  579. })
  580. describe('with comments that need encoding', function () {
  581. beforeEach(function (ctx) {
  582. ctx.ranges.comments = [{ op: { c: 'räksmörgås' } }]
  583. ctx.WebsocketController.joinDoc(
  584. ctx.client,
  585. ctx.doc_id,
  586. -1,
  587. { encodeRanges: true },
  588. ctx.callback
  589. )
  590. })
  591. it('should call the callback with the encoded comment', function (ctx) {
  592. const encodedComments = ctx.callback.args[0][4]
  593. const encodedComment = encodedComments.comments.pop()
  594. const encodedCommentText = encodedComment.op.c
  595. encodedCommentText.should.equal('räksmörgås')
  596. })
  597. })
  598. describe('with changes that need encoding', function () {
  599. it('should call the callback with the encoded insert change', function (ctx) {
  600. ctx.ranges.changes = [{ op: { i: 'räksmörgås' } }]
  601. ctx.WebsocketController.joinDoc(
  602. ctx.client,
  603. ctx.doc_id,
  604. -1,
  605. { encodeRanges: true },
  606. ctx.callback
  607. )
  608. const encodedChanges = ctx.callback.args[0][4]
  609. const encodedChange = encodedChanges.changes.pop()
  610. const encodedChangeText = encodedChange.op.i
  611. encodedChangeText.should.equal('räksmörgås')
  612. })
  613. it('should call the callback with the encoded delete change', function (ctx) {
  614. ctx.ranges.changes = [{ op: { d: 'räksmörgås' } }]
  615. ctx.WebsocketController.joinDoc(
  616. ctx.client,
  617. ctx.doc_id,
  618. -1,
  619. { encodeRanges: true },
  620. ctx.callback
  621. )
  622. const encodedChanges = ctx.callback.args[0][4]
  623. const encodedChange = encodedChanges.changes.pop()
  624. const encodedChangeText = encodedChange.op.d
  625. encodedChangeText.should.equal('räksmörgås')
  626. })
  627. })
  628. describe('when not authorized', function () {
  629. beforeEach(function (ctx) {
  630. ctx.AuthorizationManager.assertClientCanViewProject = sinon
  631. .stub()
  632. .callsArgWith(1, (ctx.err = new Error('not authorized')))
  633. ctx.WebsocketController.joinDoc(
  634. ctx.client,
  635. ctx.doc_id,
  636. -1,
  637. ctx.options,
  638. ctx.callback
  639. )
  640. })
  641. it('should call the callback with an error', function (ctx) {
  642. ctx.callback
  643. .calledWith(sinon.match({ message: 'not authorized' }))
  644. .should.equal(true)
  645. })
  646. it('should not call the DocumentUpdaterManager', function (ctx) {
  647. ctx.DocumentUpdaterManager.getDocument.called.should.equal(false)
  648. })
  649. })
  650. describe('with a restricted client', function () {
  651. beforeEach(function (ctx) {
  652. ctx.ranges.comments = [{ op: { a: 1 } }, { op: { a: 2 } }]
  653. ctx.client.ol_context.is_restricted_user = true
  654. ctx.WebsocketController.joinDoc(
  655. ctx.client,
  656. ctx.doc_id,
  657. -1,
  658. ctx.options,
  659. ctx.callback
  660. )
  661. })
  662. it('should overwrite ranges.comments with an empty list', function (ctx) {
  663. const ranges = ctx.callback.args[0][4]
  664. expect(ranges.comments).to.deep.equal([])
  665. })
  666. })
  667. describe('when the client has disconnected', function () {
  668. beforeEach(function (ctx) {
  669. ctx.client.disconnected = true
  670. ctx.WebsocketController.joinDoc(
  671. ctx.client,
  672. ctx.doc_id,
  673. -1,
  674. ctx.options,
  675. ctx.callback
  676. )
  677. })
  678. it('should call the callback with no details', function (ctx) {
  679. expect(ctx.callback.args[0]).to.deep.equal([])
  680. })
  681. it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) {
  682. expect(
  683. ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
  684. status: 'immediately',
  685. })
  686. ).to.equal(true)
  687. })
  688. it('should not get the document', function (ctx) {
  689. expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false)
  690. })
  691. })
  692. describe('when the client disconnects while auth checks are running', function () {
  693. beforeEach(async function (ctx) {
  694. await new Promise((resolve, reject) => {
  695. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
  696. new Error()
  697. )
  698. ctx.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => {
  699. ctx.client.disconnected = true
  700. cb()
  701. }
  702. ctx.WebsocketController.joinDoc(
  703. ctx.client,
  704. ctx.doc_id,
  705. -1,
  706. ctx.options,
  707. (...args) => {
  708. ctx.callback(...args)
  709. resolve()
  710. }
  711. )
  712. })
  713. })
  714. it('should call the callback with no details', function (ctx) {
  715. expect(ctx.callback.called).to.equal(true)
  716. expect(ctx.callback.args[0]).to.deep.equal([])
  717. })
  718. it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) {
  719. expect(
  720. ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
  721. status: 'after-client-auth-check',
  722. })
  723. ).to.equal(true)
  724. })
  725. it('should not get the document', function (ctx) {
  726. expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false)
  727. })
  728. })
  729. describe('when the client starts a parallel joinDoc request', function () {
  730. beforeEach(async function (ctx) {
  731. await new Promise((resolve, reject) => {
  732. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
  733. new Error()
  734. )
  735. ctx.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => {
  736. ctx.DocumentUpdaterManager.checkDocument = sinon.stub().yields()
  737. ctx.WebsocketController.joinDoc(
  738. ctx.client,
  739. ctx.doc_id,
  740. -1,
  741. {},
  742. () => {}
  743. )
  744. cb()
  745. }
  746. ctx.WebsocketController.joinDoc(
  747. ctx.client,
  748. ctx.doc_id,
  749. -1,
  750. ctx.options,
  751. (...args) => {
  752. ctx.callback(...args)
  753. // make sure the other joinDoc request completed
  754. setTimeout(resolve, 5)
  755. }
  756. )
  757. })
  758. })
  759. it('should call the callback with an error', function (ctx) {
  760. expect(ctx.callback.called).to.equal(true)
  761. expect(ctx.callback.args[0][0].message).to.equal(
  762. 'joinLeaveEpoch mismatch'
  763. )
  764. })
  765. it('should get the document once (the parallel request wins)', function (ctx) {
  766. expect(ctx.DocumentUpdaterManager.getDocument.callCount).to.equal(1)
  767. })
  768. })
  769. describe('when the client starts a parallel leaveDoc request', function () {
  770. beforeEach(async function (ctx) {
  771. await new Promise((resolve, reject) => {
  772. ctx.RoomManager.leaveDoc = sinon.stub()
  773. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
  774. new Error()
  775. )
  776. ctx.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => {
  777. ctx.WebsocketController.leaveDoc(ctx.client, ctx.doc_id, () => {})
  778. cb()
  779. }
  780. ctx.WebsocketController.joinDoc(
  781. ctx.client,
  782. ctx.doc_id,
  783. -1,
  784. ctx.options,
  785. (...args) => {
  786. ctx.callback(...args)
  787. resolve()
  788. }
  789. )
  790. })
  791. })
  792. it('should call the callback with an error', function (ctx) {
  793. expect(ctx.callback.called).to.equal(true)
  794. expect(ctx.callback.args[0][0].message).to.equal(
  795. 'joinLeaveEpoch mismatch'
  796. )
  797. })
  798. it('should not get the document', function (ctx) {
  799. expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false)
  800. })
  801. })
  802. describe('when the client disconnects while RoomManager.joinDoc is running', function () {
  803. beforeEach(function (ctx) {
  804. ctx.RoomManager.joinDoc = (client, docId, cb) => {
  805. ctx.client.disconnected = true
  806. cb()
  807. }
  808. ctx.WebsocketController.joinDoc(
  809. ctx.client,
  810. ctx.doc_id,
  811. -1,
  812. ctx.options,
  813. ctx.callback
  814. )
  815. })
  816. it('should call the callback with no details', function (ctx) {
  817. expect(ctx.callback.args[0]).to.deep.equal([])
  818. })
  819. it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) {
  820. expect(
  821. ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
  822. status: 'after-joining-room',
  823. })
  824. ).to.equal(true)
  825. })
  826. it('should not get the document', function (ctx) {
  827. expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false)
  828. })
  829. })
  830. describe('when the client disconnects while DocumentUpdaterManager.getDocument is running', function () {
  831. beforeEach(function (ctx) {
  832. ctx.DocumentUpdaterManager.getDocument = (
  833. projectId,
  834. docId,
  835. fromVersion,
  836. callback
  837. ) => {
  838. ctx.client.disconnected = true
  839. callback(null, ctx.doc_lines, ctx.version, ctx.ranges, ctx.ops)
  840. }
  841. ctx.WebsocketController.joinDoc(
  842. ctx.client,
  843. ctx.doc_id,
  844. -1,
  845. ctx.options,
  846. ctx.callback
  847. )
  848. })
  849. it('should call the callback with no details', function (ctx) {
  850. expect(ctx.callback.args[0]).to.deep.equal([])
  851. })
  852. it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) {
  853. expect(
  854. ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
  855. status: 'after-doc-updater-call',
  856. })
  857. ).to.equal(true)
  858. })
  859. })
  860. })
  861. describe('leaveDoc', function () {
  862. beforeEach(function (ctx) {
  863. ctx.doc_id = 'doc-id-123'
  864. ctx.client.ol_context.project_id = ctx.project_id
  865. ctx.RoomManager.leaveDoc = sinon.stub()
  866. ctx.WebsocketController.leaveDoc(ctx.client, ctx.doc_id, ctx.callback)
  867. })
  868. it('should inc the joinLeaveEpoch', function (ctx) {
  869. expect(ctx.client.joinLeaveEpoch).to.equal(1)
  870. })
  871. it('should remove the client from the doc_id room', function (ctx) {
  872. ctx.RoomManager.leaveDoc
  873. .calledWith(ctx.client, ctx.doc_id)
  874. .should.equal(true)
  875. })
  876. it('should call the callback', function (ctx) {
  877. ctx.callback.called.should.equal(true)
  878. })
  879. it('should increment the leave-doc metric', function (ctx) {
  880. ctx.metrics.inc.calledWith('editor.leave-doc').should.equal(true)
  881. })
  882. })
  883. describe('getConnectedUsers', function () {
  884. beforeEach(function (ctx) {
  885. ctx.client.ol_context.project_id = ctx.project_id
  886. ctx.users = ['mock', 'users']
  887. ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub()
  888. ctx.ConnectedUsersManager.getConnectedUsers = sinon
  889. .stub()
  890. .callsArgWith(1, null, ctx.users)
  891. })
  892. describe('when authorized', function () {
  893. beforeEach(async function (ctx) {
  894. await new Promise((resolve, reject) => {
  895. ctx.AuthorizationManager.assertClientCanViewProject = sinon
  896. .stub()
  897. .callsArgWith(1, null)
  898. ctx.WebsocketController.getConnectedUsers(ctx.client, (...args) => {
  899. ctx.callback(...Array.from(args || []))
  900. resolve()
  901. })
  902. })
  903. })
  904. it('should check that the client is authorized to view the project', function (ctx) {
  905. ctx.AuthorizationManager.assertClientCanViewProject
  906. .calledWith(ctx.client)
  907. .should.equal(true)
  908. })
  909. it('should broadcast a request to update the client list', function (ctx) {
  910. ctx.WebsocketLoadBalancer.emitToRoom
  911. .calledWith(ctx.project_id, 'clientTracking.refresh')
  912. .should.equal(true)
  913. })
  914. it('should get the connected users for the project', function (ctx) {
  915. ctx.ConnectedUsersManager.getConnectedUsers
  916. .calledWith(ctx.project_id)
  917. .should.equal(true)
  918. })
  919. it('should the users', function (ctx) {
  920. ctx.callback.calledWith(null, ctx.users).should.equal(true)
  921. })
  922. it('should increment the get-connected-users metric', function (ctx) {
  923. ctx.metrics.inc
  924. .calledWith('editor.get-connected-users')
  925. .should.equal(true)
  926. })
  927. })
  928. describe('when not authorized', function () {
  929. beforeEach(function (ctx) {
  930. ctx.AuthorizationManager.assertClientCanViewProject = sinon
  931. .stub()
  932. .callsArgWith(1, (ctx.err = new Error('not authorized')))
  933. ctx.WebsocketController.getConnectedUsers(ctx.client, ctx.callback)
  934. })
  935. it('should not get the connected users for the project', function (ctx) {
  936. ctx.ConnectedUsersManager.getConnectedUsers.called.should.equal(false)
  937. })
  938. it('should return an error', function (ctx) {
  939. ctx.callback.calledWith(ctx.err).should.equal(true)
  940. })
  941. })
  942. describe('when restricted user', function () {
  943. beforeEach(function (ctx) {
  944. ctx.client.ol_context.is_restricted_user = true
  945. ctx.AuthorizationManager.assertClientCanViewProject = sinon
  946. .stub()
  947. .callsArgWith(1, null)
  948. ctx.WebsocketController.getConnectedUsers(ctx.client, ctx.callback)
  949. })
  950. it('should return an empty array of users', function (ctx) {
  951. ctx.callback.calledWith(null, []).should.equal(true)
  952. })
  953. it('should not get the connected users for the project', function (ctx) {
  954. ctx.ConnectedUsersManager.getConnectedUsers.called.should.equal(false)
  955. })
  956. })
  957. describe('when the client has disconnected', function () {
  958. beforeEach(function (ctx) {
  959. ctx.client.disconnected = true
  960. ctx.AuthorizationManager.assertClientCanViewProject = sinon.stub()
  961. ctx.WebsocketController.getConnectedUsers(ctx.client, ctx.callback)
  962. })
  963. it('should call the callback with no details', function (ctx) {
  964. expect(ctx.callback.args[0]).to.deep.equal([])
  965. })
  966. it('should not check permissions', function (ctx) {
  967. expect(
  968. ctx.AuthorizationManager.assertClientCanViewProject.called
  969. ).to.equal(false)
  970. })
  971. })
  972. })
  973. describe('updateClientPosition', function () {
  974. beforeEach(function (ctx) {
  975. ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub()
  976. ctx.ConnectedUsersManager.updateUserPosition = sinon
  977. .stub()
  978. .callsArgAsync(4)
  979. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon
  980. .stub()
  981. .callsArgWith(2, null)
  982. ctx.update = {
  983. doc_id: (ctx.doc_id = 'doc-id-123'),
  984. row: (ctx.row = 42),
  985. column: (ctx.column = 37),
  986. }
  987. })
  988. describe('with a logged in user', function () {
  989. beforeEach(async function (ctx) {
  990. await new Promise((resolve, reject) => {
  991. ctx.client.ol_context = {
  992. project_id: ctx.project_id,
  993. first_name: (ctx.first_name = 'Douglas'),
  994. last_name: (ctx.last_name = 'Adams'),
  995. email: (ctx.email = 'joe@example.com'),
  996. user_id: (ctx.user_id = 'user-id-123'),
  997. }
  998. ctx.populatedCursorData = {
  999. doc_id: ctx.doc_id,
  1000. id: ctx.client.publicId,
  1001. name: `${ctx.first_name} ${ctx.last_name}`,
  1002. row: ctx.row,
  1003. column: ctx.column,
  1004. email: ctx.email,
  1005. user_id: ctx.user_id,
  1006. }
  1007. ctx.WebsocketController.updateClientPosition(
  1008. ctx.client,
  1009. ctx.update,
  1010. err => {
  1011. if (err) return reject(err)
  1012. resolve()
  1013. }
  1014. )
  1015. })
  1016. })
  1017. it("should send the update to the project room with the user's name", function (ctx) {
  1018. ctx.WebsocketLoadBalancer.emitToRoom
  1019. .calledWith(
  1020. ctx.project_id,
  1021. 'clientTracking.clientUpdated',
  1022. ctx.populatedCursorData
  1023. )
  1024. .should.equal(true)
  1025. })
  1026. it('should send the cursor data to the connected user manager', async function (ctx) {
  1027. await new Promise((resolve, reject) => {
  1028. ctx.ConnectedUsersManager.updateUserPosition
  1029. .calledWith(
  1030. ctx.project_id,
  1031. ctx.client.publicId,
  1032. {
  1033. _id: ctx.user_id,
  1034. email: ctx.email,
  1035. first_name: ctx.first_name,
  1036. last_name: ctx.last_name,
  1037. },
  1038. {
  1039. row: ctx.row,
  1040. column: ctx.column,
  1041. doc_id: ctx.doc_id,
  1042. }
  1043. )
  1044. .should.equal(true)
  1045. resolve()
  1046. })
  1047. })
  1048. it('should increment the update-client-position metric at 0.1 frequency', function (ctx) {
  1049. ctx.metrics.inc
  1050. .calledWith('editor.update-client-position', 0.1)
  1051. .should.equal(true)
  1052. })
  1053. })
  1054. describe('with a logged in user who has no last_name set', function () {
  1055. beforeEach(async function (ctx) {
  1056. await new Promise((resolve, reject) => {
  1057. ctx.client.ol_context = {
  1058. project_id: ctx.project_id,
  1059. first_name: (ctx.first_name = 'Douglas'),
  1060. last_name: undefined,
  1061. email: (ctx.email = 'joe@example.com'),
  1062. user_id: (ctx.user_id = 'user-id-123'),
  1063. }
  1064. ctx.populatedCursorData = {
  1065. doc_id: ctx.doc_id,
  1066. id: ctx.client.publicId,
  1067. name: `${ctx.first_name}`,
  1068. row: ctx.row,
  1069. column: ctx.column,
  1070. email: ctx.email,
  1071. user_id: ctx.user_id,
  1072. }
  1073. ctx.WebsocketController.updateClientPosition(
  1074. ctx.client,
  1075. ctx.update,
  1076. err => {
  1077. if (err) return reject(err)
  1078. resolve()
  1079. }
  1080. )
  1081. })
  1082. })
  1083. it("should send the update to the project room with the user's name", function (ctx) {
  1084. ctx.WebsocketLoadBalancer.emitToRoom
  1085. .calledWith(
  1086. ctx.project_id,
  1087. 'clientTracking.clientUpdated',
  1088. ctx.populatedCursorData
  1089. )
  1090. .should.equal(true)
  1091. })
  1092. it('should send the cursor data to the connected user manager', async function (ctx) {
  1093. await new Promise((resolve, reject) => {
  1094. ctx.ConnectedUsersManager.updateUserPosition
  1095. .calledWith(
  1096. ctx.project_id,
  1097. ctx.client.publicId,
  1098. {
  1099. _id: ctx.user_id,
  1100. email: ctx.email,
  1101. first_name: ctx.first_name,
  1102. last_name: undefined,
  1103. },
  1104. {
  1105. row: ctx.row,
  1106. column: ctx.column,
  1107. doc_id: ctx.doc_id,
  1108. }
  1109. )
  1110. .should.equal(true)
  1111. resolve()
  1112. })
  1113. })
  1114. it('should increment the update-client-position metric at 0.1 frequency', function (ctx) {
  1115. ctx.metrics.inc
  1116. .calledWith('editor.update-client-position', 0.1)
  1117. .should.equal(true)
  1118. })
  1119. })
  1120. describe('with a logged in user who has no first_name set', function () {
  1121. beforeEach(async function (ctx) {
  1122. await new Promise((resolve, reject) => {
  1123. ctx.client.ol_context = {
  1124. project_id: ctx.project_id,
  1125. first_name: undefined,
  1126. last_name: (ctx.last_name = 'Adams'),
  1127. email: (ctx.email = 'joe@example.com'),
  1128. user_id: (ctx.user_id = 'user-id-123'),
  1129. }
  1130. ctx.populatedCursorData = {
  1131. doc_id: ctx.doc_id,
  1132. id: ctx.client.publicId,
  1133. name: `${ctx.last_name}`,
  1134. row: ctx.row,
  1135. column: ctx.column,
  1136. email: ctx.email,
  1137. user_id: ctx.user_id,
  1138. }
  1139. ctx.WebsocketController.updateClientPosition(
  1140. ctx.client,
  1141. ctx.update,
  1142. err => {
  1143. if (err) return reject(err)
  1144. resolve()
  1145. }
  1146. )
  1147. })
  1148. })
  1149. it("should send the update to the project room with the user's name", function (ctx) {
  1150. ctx.WebsocketLoadBalancer.emitToRoom
  1151. .calledWith(
  1152. ctx.project_id,
  1153. 'clientTracking.clientUpdated',
  1154. ctx.populatedCursorData
  1155. )
  1156. .should.equal(true)
  1157. })
  1158. it('should send the cursor data to the connected user manager', async function (ctx) {
  1159. await new Promise((resolve, reject) => {
  1160. ctx.ConnectedUsersManager.updateUserPosition
  1161. .calledWith(
  1162. ctx.project_id,
  1163. ctx.client.publicId,
  1164. {
  1165. _id: ctx.user_id,
  1166. email: ctx.email,
  1167. first_name: undefined,
  1168. last_name: ctx.last_name,
  1169. },
  1170. {
  1171. row: ctx.row,
  1172. column: ctx.column,
  1173. doc_id: ctx.doc_id,
  1174. }
  1175. )
  1176. .should.equal(true)
  1177. resolve()
  1178. })
  1179. })
  1180. it('should increment the update-client-position metric at 0.1 frequency', function (ctx) {
  1181. ctx.metrics.inc
  1182. .calledWith('editor.update-client-position', 0.1)
  1183. .should.equal(true)
  1184. })
  1185. })
  1186. describe('with a logged in user who has no names set', function () {
  1187. beforeEach(async function (ctx) {
  1188. await new Promise((resolve, reject) => {
  1189. ctx.client.ol_context = {
  1190. project_id: ctx.project_id,
  1191. first_name: undefined,
  1192. last_name: undefined,
  1193. email: (ctx.email = 'joe@example.com'),
  1194. user_id: (ctx.user_id = 'user-id-123'),
  1195. }
  1196. ctx.WebsocketController.updateClientPosition(
  1197. ctx.client,
  1198. ctx.update,
  1199. err => {
  1200. if (err) return reject(err)
  1201. resolve()
  1202. }
  1203. )
  1204. })
  1205. })
  1206. it('should send the update to the project name with no name', function (ctx) {
  1207. ctx.WebsocketLoadBalancer.emitToRoom
  1208. .calledWith(ctx.project_id, 'clientTracking.clientUpdated', {
  1209. doc_id: ctx.doc_id,
  1210. id: ctx.client.publicId,
  1211. user_id: ctx.user_id,
  1212. name: '',
  1213. row: ctx.row,
  1214. column: ctx.column,
  1215. email: ctx.email,
  1216. })
  1217. .should.equal(true)
  1218. })
  1219. })
  1220. describe('with an anonymous user', function () {
  1221. beforeEach(async function (ctx) {
  1222. await new Promise((resolve, reject) => {
  1223. ctx.client.ol_context = {
  1224. project_id: ctx.project_id,
  1225. }
  1226. ctx.WebsocketController.updateClientPosition(
  1227. ctx.client,
  1228. ctx.update,
  1229. err => {
  1230. if (err) return reject(err)
  1231. resolve()
  1232. }
  1233. )
  1234. })
  1235. })
  1236. it('should send the update to the project room with no name', function (ctx) {
  1237. ctx.WebsocketLoadBalancer.emitToRoom
  1238. .calledWith(ctx.project_id, 'clientTracking.clientUpdated', {
  1239. doc_id: ctx.doc_id,
  1240. id: ctx.client.publicId,
  1241. name: '',
  1242. row: ctx.row,
  1243. column: ctx.column,
  1244. })
  1245. .should.equal(true)
  1246. })
  1247. it('should not send cursor data to the connected user manager', async function (ctx) {
  1248. await new Promise((resolve, reject) => {
  1249. ctx.ConnectedUsersManager.updateUserPosition.called.should.equal(
  1250. false
  1251. )
  1252. resolve()
  1253. })
  1254. })
  1255. })
  1256. describe('when the client has disconnected', function () {
  1257. beforeEach(async function (ctx) {
  1258. await new Promise((resolve, reject) => {
  1259. ctx.client.disconnected = true
  1260. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc =
  1261. sinon.stub()
  1262. ctx.WebsocketController.updateClientPosition(
  1263. ctx.client,
  1264. ctx.update,
  1265. (...args) => {
  1266. ctx.callback(...args)
  1267. if (args[0]) return reject(args[0])
  1268. resolve()
  1269. }
  1270. )
  1271. })
  1272. })
  1273. it('should call the callback with no details', function (ctx) {
  1274. expect(ctx.callback.args[0]).to.deep.equal([])
  1275. })
  1276. it('should not check permissions', function (ctx) {
  1277. expect(
  1278. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.called
  1279. ).to.equal(false)
  1280. })
  1281. })
  1282. })
  1283. describe('applyOtUpdate', function () {
  1284. beforeEach(function (ctx) {
  1285. ctx.update = { op: { p: 12, t: 'foo' } }
  1286. ctx.client.ol_context.user_id = ctx.user_id
  1287. ctx.client.ol_context.project_id = ctx.project_id
  1288. ctx.WebsocketController._assertClientCanApplyUpdate = sinon
  1289. .stub()
  1290. .yields()
  1291. ctx.DocumentUpdaterManager.queueChange = sinon.stub().callsArg(3)
  1292. })
  1293. describe('succesfully', function () {
  1294. beforeEach(function (ctx) {
  1295. ctx.WebsocketController.applyOtUpdate(
  1296. ctx.client,
  1297. ctx.doc_id,
  1298. ctx.update,
  1299. ctx.callback
  1300. )
  1301. })
  1302. it('should set the source of the update to the client id', function (ctx) {
  1303. ctx.update.meta.source.should.equal(ctx.client.publicId)
  1304. })
  1305. it('should set the user_id of the update to the user id', function (ctx) {
  1306. ctx.update.meta.user_id.should.equal(ctx.user_id)
  1307. })
  1308. it('should queue the update', function (ctx) {
  1309. ctx.DocumentUpdaterManager.queueChange
  1310. .calledWith(ctx.project_id, ctx.doc_id, ctx.update)
  1311. .should.equal(true)
  1312. })
  1313. it('should call the callback', function (ctx) {
  1314. ctx.callback.called.should.equal(true)
  1315. })
  1316. it('should increment the doc updates', function (ctx) {
  1317. ctx.metrics.inc.calledWith('editor.doc-update').should.equal(true)
  1318. })
  1319. })
  1320. describe('unsuccessfully', function () {
  1321. beforeEach(function (ctx) {
  1322. ctx.client.disconnect = sinon.stub()
  1323. ctx.DocumentUpdaterManager.queueChange = sinon
  1324. .stub()
  1325. .callsArgWith(3, (ctx.error = new Error('Something went wrong')))
  1326. ctx.WebsocketController.applyOtUpdate(
  1327. ctx.client,
  1328. ctx.doc_id,
  1329. ctx.update,
  1330. ctx.callback
  1331. )
  1332. })
  1333. it('should disconnect the client', function (ctx) {
  1334. ctx.client.disconnect.called.should.equal(true)
  1335. })
  1336. it('should not log an error', function (ctx) {
  1337. ctx.logger.error.called.should.equal(false)
  1338. })
  1339. it('should call the callback with the error', function (ctx) {
  1340. ctx.callback.calledWith(ctx.error).should.equal(true)
  1341. })
  1342. })
  1343. describe('when not authorized', function () {
  1344. beforeEach(function (ctx) {
  1345. ctx.client.disconnect = sinon.stub()
  1346. ctx.WebsocketController._assertClientCanApplyUpdate = sinon
  1347. .stub()
  1348. .yields((ctx.error = new Error('not authorized')))
  1349. ctx.WebsocketController.applyOtUpdate(
  1350. ctx.client,
  1351. ctx.doc_id,
  1352. ctx.update,
  1353. ctx.callback
  1354. )
  1355. })
  1356. // This happens in a setTimeout to allow the client a chance to receive the error first.
  1357. // I'm not sure how to unit test, but it is acceptance tested.
  1358. // it "should disconnect the client", ->
  1359. // @client.disconnect.called.should.equal true
  1360. it('should not log a warning', function (ctx) {
  1361. ctx.logger.warn.called.should.equal(false)
  1362. })
  1363. it('should call the callback with the error', function (ctx) {
  1364. ctx.callback.calledWith(ctx.error).should.equal(true)
  1365. })
  1366. })
  1367. describe('update_too_large', function () {
  1368. beforeEach(async function (ctx) {
  1369. await new Promise((resolve, reject) => {
  1370. ctx.client.disconnect = sinon.stub()
  1371. ctx.client.emit = sinon.stub()
  1372. ctx.client.ol_context.user_id = ctx.user_id
  1373. ctx.client.ol_context.project_id = ctx.project_id
  1374. const error = new UpdateTooLargeError(7372835)
  1375. ctx.DocumentUpdaterManager.queueChange = sinon
  1376. .stub()
  1377. .callsArgWith(3, error)
  1378. ctx.WebsocketController.applyOtUpdate(
  1379. ctx.client,
  1380. ctx.doc_id,
  1381. ctx.update,
  1382. ctx.callback
  1383. )
  1384. setTimeout(() => resolve(), 1)
  1385. })
  1386. })
  1387. it('should call the callback with no error', function (ctx) {
  1388. ctx.callback.called.should.equal(true)
  1389. ctx.callback.args[0].should.deep.equal([])
  1390. })
  1391. it('should log a warning with the size and context', function (ctx) {
  1392. ctx.logger.warn.called.should.equal(true)
  1393. ctx.logger.warn.args[0].should.deep.equal([
  1394. {
  1395. userId: ctx.user_id,
  1396. projectId: ctx.project_id,
  1397. docId: ctx.doc_id,
  1398. updateSize: 7372835,
  1399. },
  1400. 'update is too large',
  1401. ])
  1402. })
  1403. describe('after 100ms', function () {
  1404. beforeEach(async function () {
  1405. await new Promise(resolve => {
  1406. setTimeout(resolve, 100)
  1407. })
  1408. })
  1409. it('should send an otUpdateError the client', function (ctx) {
  1410. ctx.client.emit.calledWith('otUpdateError').should.equal(true)
  1411. })
  1412. it('should disconnect the client', function (ctx) {
  1413. ctx.client.disconnect.called.should.equal(true)
  1414. })
  1415. })
  1416. describe('when the client disconnects during the next 100ms', function () {
  1417. beforeEach(async function (ctx) {
  1418. await new Promise((resolve, reject) => {
  1419. ctx.client.disconnected = true
  1420. setTimeout(resolve, 100)
  1421. })
  1422. })
  1423. it('should not send an otUpdateError the client', function (ctx) {
  1424. ctx.client.emit.calledWith('otUpdateError').should.equal(false)
  1425. })
  1426. it('should not disconnect the client', function (ctx) {
  1427. ctx.client.disconnect.called.should.equal(false)
  1428. })
  1429. it('should increment the editor.doc-update.disconnected metric with a status', function (ctx) {
  1430. expect(
  1431. ctx.metrics.inc.calledWith('editor.doc-update.disconnected', 1, {
  1432. status: 'at-otUpdateError',
  1433. })
  1434. ).to.equal(true)
  1435. })
  1436. })
  1437. })
  1438. })
  1439. describe('_assertClientCanApplyUpdate', function () {
  1440. beforeEach(function (ctx) {
  1441. ctx.edit_update = {
  1442. op: [
  1443. { i: 'foo', p: 42 },
  1444. { c: 'bar', p: 132 },
  1445. ],
  1446. } // comments may still be in an edit op
  1447. ctx.comment_update = { op: [{ c: 'bar', p: 132 }] }
  1448. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc = sinon.stub()
  1449. ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc = sinon.stub()
  1450. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub()
  1451. })
  1452. describe('with a read-write client', function () {
  1453. it('should return successfully', async function (ctx) {
  1454. await new Promise((resolve, reject) => {
  1455. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(null)
  1456. ctx.WebsocketController._assertClientCanApplyUpdate(
  1457. ctx.client,
  1458. ctx.doc_id,
  1459. ctx.edit_update,
  1460. error => {
  1461. expect(error).to.be.null
  1462. resolve()
  1463. }
  1464. )
  1465. })
  1466. })
  1467. })
  1468. describe('with a read-only client and an edit op', function () {
  1469. it('should return an error', async function (ctx) {
  1470. await new Promise((resolve, reject) => {
  1471. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
  1472. new Error('not authorized')
  1473. )
  1474. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
  1475. ctx.WebsocketController._assertClientCanApplyUpdate(
  1476. ctx.client,
  1477. ctx.doc_id,
  1478. ctx.edit_update,
  1479. error => {
  1480. expect(error.message).to.equal('not authorized')
  1481. resolve()
  1482. }
  1483. )
  1484. })
  1485. })
  1486. })
  1487. describe('with a read-only client and a comment op', function () {
  1488. it('should return successfully', async function (ctx) {
  1489. await new Promise((resolve, reject) => {
  1490. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
  1491. new Error('not authorized')
  1492. )
  1493. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
  1494. ctx.WebsocketController._assertClientCanApplyUpdate(
  1495. ctx.client,
  1496. ctx.doc_id,
  1497. ctx.comment_update,
  1498. error => {
  1499. expect(error).to.be.null
  1500. resolve()
  1501. }
  1502. )
  1503. })
  1504. })
  1505. })
  1506. describe('with a totally unauthorized client', function () {
  1507. it('should return an error', async function (ctx) {
  1508. await new Promise((resolve, reject) => {
  1509. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
  1510. new Error('not authorized')
  1511. )
  1512. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
  1513. new Error('not authorized')
  1514. )
  1515. ctx.WebsocketController._assertClientCanApplyUpdate(
  1516. ctx.client,
  1517. ctx.doc_id,
  1518. ctx.comment_update,
  1519. error => {
  1520. expect(error.message).to.equal('not authorized')
  1521. resolve()
  1522. }
  1523. )
  1524. })
  1525. })
  1526. })
  1527. describe('with a review client', function () {
  1528. it('op with tc should succeed', async function (ctx) {
  1529. await new Promise((resolve, reject) => {
  1530. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
  1531. new Error('not authorized')
  1532. )
  1533. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
  1534. ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc.yields(
  1535. null
  1536. )
  1537. ctx.WebsocketController._assertClientCanApplyUpdate(
  1538. ctx.client,
  1539. ctx.doc_id,
  1540. { op: [{ p: 10, i: 'a' }], meta: { tc: '123456' } },
  1541. error => {
  1542. expect(error).to.be.null
  1543. resolve()
  1544. }
  1545. )
  1546. })
  1547. })
  1548. it('op without tc should fail', async function (ctx) {
  1549. await new Promise((resolve, reject) => {
  1550. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
  1551. new Error('not authorized')
  1552. )
  1553. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
  1554. ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc.yields(
  1555. null
  1556. )
  1557. ctx.WebsocketController._assertClientCanApplyUpdate(
  1558. ctx.client,
  1559. ctx.doc_id,
  1560. { op: [{ p: 10, i: 'a' }] },
  1561. error => {
  1562. expect(error.message).to.equal('not authorized')
  1563. resolve()
  1564. }
  1565. )
  1566. })
  1567. })
  1568. })
  1569. })
  1570. })