WebsocketController.test.js 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782
  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, {
  500. lines: ctx.doc_lines,
  501. version: ctx.version,
  502. ranges: ctx.ranges,
  503. ops: ctx.ops,
  504. })
  505. ctx.RoomManager.joinDoc = sinon.stub().callsArg(2)
  506. })
  507. describe('works', function () {
  508. beforeEach(function (ctx) {
  509. ctx.WebsocketController.joinDoc(
  510. ctx.client,
  511. ctx.doc_id,
  512. -1,
  513. ctx.options,
  514. ctx.callback
  515. )
  516. })
  517. it('should inc the joinLeaveEpoch', function (ctx) {
  518. expect(ctx.client.joinLeaveEpoch).to.equal(1)
  519. })
  520. it('should check that the client is authorized to view the project', function (ctx) {
  521. ctx.AuthorizationManager.assertClientCanViewProject
  522. .calledWith(ctx.client)
  523. .should.equal(true)
  524. })
  525. it('should get the document from the DocumentUpdaterManager with fromVersion', function (ctx) {
  526. ctx.DocumentUpdaterManager.getDocument
  527. .calledWith(ctx.project_id, ctx.doc_id, -1)
  528. .should.equal(true)
  529. })
  530. it('should add permissions for the client to access the doc', function (ctx) {
  531. ctx.AuthorizationManager.addAccessToDoc
  532. .calledWith(ctx.client, ctx.doc_id)
  533. .should.equal(true)
  534. })
  535. it('should join the client to room for the doc_id', function (ctx) {
  536. ctx.RoomManager.joinDoc
  537. .calledWith(ctx.client, ctx.doc_id)
  538. .should.equal(true)
  539. })
  540. it('should call the callback with the lines, version, ranges and ops', function (ctx) {
  541. ctx.callback
  542. .calledWith(null, ctx.doc_lines, ctx.version, ctx.ops, ctx.ranges)
  543. .should.equal(true)
  544. })
  545. it('should increment the join-doc metric', function (ctx) {
  546. ctx.metrics.inc.calledWith('editor.join-doc').should.equal(true)
  547. })
  548. })
  549. describe('with a fromVersion', function () {
  550. beforeEach(function (ctx) {
  551. ctx.fromVersion = 40
  552. ctx.WebsocketController.joinDoc(
  553. ctx.client,
  554. ctx.doc_id,
  555. ctx.fromVersion,
  556. ctx.options,
  557. ctx.callback
  558. )
  559. })
  560. it('should get the document from the DocumentUpdaterManager with fromVersion', function (ctx) {
  561. ctx.DocumentUpdaterManager.getDocument
  562. .calledWith(ctx.project_id, ctx.doc_id, ctx.fromVersion)
  563. .should.equal(true)
  564. })
  565. })
  566. describe('with doclines that need escaping', function () {
  567. beforeEach(function (ctx) {
  568. ctx.doc_lines.push(['räksmörgås'])
  569. ctx.WebsocketController.joinDoc(
  570. ctx.client,
  571. ctx.doc_id,
  572. -1,
  573. ctx.options,
  574. ctx.callback
  575. )
  576. })
  577. it('should call the callback with the escaped lines', function (ctx) {
  578. const escapedLines = ctx.callback.args[0][1]
  579. const escapedWord = escapedLines.pop()
  580. escapedWord.should.equal('räksmörgås')
  581. // Check that unescaping works
  582. decodeURIComponent(escape(escapedWord)).should.equal('räksmörgås')
  583. })
  584. })
  585. describe('with comments that need encoding', function () {
  586. beforeEach(function (ctx) {
  587. ctx.ranges.comments = [{ op: { c: 'räksmörgås' } }]
  588. ctx.WebsocketController.joinDoc(
  589. ctx.client,
  590. ctx.doc_id,
  591. -1,
  592. { encodeRanges: true },
  593. ctx.callback
  594. )
  595. })
  596. it('should call the callback with the encoded comment', function (ctx) {
  597. const encodedComments = ctx.callback.args[0][4]
  598. const encodedComment = encodedComments.comments.pop()
  599. const encodedCommentText = encodedComment.op.c
  600. encodedCommentText.should.equal('räksmörgås')
  601. })
  602. })
  603. describe('with changes that need encoding', function () {
  604. it('should call the callback with the encoded insert change', function (ctx) {
  605. ctx.ranges.changes = [{ op: { i: 'räksmörgås' } }]
  606. ctx.WebsocketController.joinDoc(
  607. ctx.client,
  608. ctx.doc_id,
  609. -1,
  610. { encodeRanges: true },
  611. ctx.callback
  612. )
  613. const encodedChanges = ctx.callback.args[0][4]
  614. const encodedChange = encodedChanges.changes.pop()
  615. const encodedChangeText = encodedChange.op.i
  616. encodedChangeText.should.equal('räksmörgås')
  617. })
  618. it('should call the callback with the encoded delete change', function (ctx) {
  619. ctx.ranges.changes = [{ op: { d: 'räksmörgås' } }]
  620. ctx.WebsocketController.joinDoc(
  621. ctx.client,
  622. ctx.doc_id,
  623. -1,
  624. { encodeRanges: true },
  625. ctx.callback
  626. )
  627. const encodedChanges = ctx.callback.args[0][4]
  628. const encodedChange = encodedChanges.changes.pop()
  629. const encodedChangeText = encodedChange.op.d
  630. encodedChangeText.should.equal('räksmörgås')
  631. })
  632. })
  633. describe('when not authorized', function () {
  634. beforeEach(function (ctx) {
  635. ctx.AuthorizationManager.assertClientCanViewProject = sinon
  636. .stub()
  637. .callsArgWith(1, (ctx.err = new Error('not authorized')))
  638. ctx.WebsocketController.joinDoc(
  639. ctx.client,
  640. ctx.doc_id,
  641. -1,
  642. ctx.options,
  643. ctx.callback
  644. )
  645. })
  646. it('should call the callback with an error', function (ctx) {
  647. ctx.callback
  648. .calledWith(sinon.match({ message: 'not authorized' }))
  649. .should.equal(true)
  650. })
  651. it('should not call the DocumentUpdaterManager', function (ctx) {
  652. ctx.DocumentUpdaterManager.getDocument.called.should.equal(false)
  653. })
  654. })
  655. describe('when the DocumentUpdaterManager returns an error', function () {
  656. beforeEach(function (ctx) {
  657. ctx.err = new Error('document updater error')
  658. ctx.DocumentUpdaterManager.getDocument = sinon
  659. .stub()
  660. .callsArgWith(3, ctx.err)
  661. ctx.WebsocketController.joinDoc(
  662. ctx.client,
  663. ctx.doc_id,
  664. -1,
  665. ctx.options,
  666. ctx.callback
  667. )
  668. })
  669. it('should call the callback with the error', function (ctx) {
  670. ctx.callback
  671. .calledWith(sinon.match({ message: 'document updater error' }))
  672. .should.equal(true)
  673. })
  674. })
  675. describe('with a restricted client', function () {
  676. beforeEach(function (ctx) {
  677. ctx.ranges.comments = [{ op: { a: 1 } }, { op: { a: 2 } }]
  678. ctx.client.ol_context.is_restricted_user = true
  679. ctx.WebsocketController.joinDoc(
  680. ctx.client,
  681. ctx.doc_id,
  682. -1,
  683. ctx.options,
  684. ctx.callback
  685. )
  686. })
  687. it('should overwrite ranges.comments with an empty list', function (ctx) {
  688. const ranges = ctx.callback.args[0][4]
  689. expect(ranges.comments).to.deep.equal([])
  690. })
  691. })
  692. describe('when the client has disconnected', function () {
  693. beforeEach(function (ctx) {
  694. ctx.client.disconnected = true
  695. ctx.WebsocketController.joinDoc(
  696. ctx.client,
  697. ctx.doc_id,
  698. -1,
  699. ctx.options,
  700. ctx.callback
  701. )
  702. })
  703. it('should call the callback with no details', function (ctx) {
  704. expect(ctx.callback.args[0]).to.deep.equal([])
  705. })
  706. it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) {
  707. expect(
  708. ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
  709. status: 'immediately',
  710. })
  711. ).to.equal(true)
  712. })
  713. it('should not get the document', function (ctx) {
  714. expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false)
  715. })
  716. })
  717. describe('when the client disconnects while auth checks are running', function () {
  718. beforeEach(async function (ctx) {
  719. await new Promise((resolve, reject) => {
  720. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
  721. new Error()
  722. )
  723. ctx.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => {
  724. ctx.client.disconnected = true
  725. cb()
  726. }
  727. ctx.WebsocketController.joinDoc(
  728. ctx.client,
  729. ctx.doc_id,
  730. -1,
  731. ctx.options,
  732. (...args) => {
  733. ctx.callback(...args)
  734. resolve()
  735. }
  736. )
  737. })
  738. })
  739. it('should call the callback with no details', function (ctx) {
  740. expect(ctx.callback.called).to.equal(true)
  741. expect(ctx.callback.args[0]).to.deep.equal([])
  742. })
  743. it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) {
  744. expect(
  745. ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
  746. status: 'after-client-auth-check',
  747. })
  748. ).to.equal(true)
  749. })
  750. it('should not get the document', function (ctx) {
  751. expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false)
  752. })
  753. })
  754. describe('when the client starts a parallel joinDoc request', function () {
  755. beforeEach(async function (ctx) {
  756. await new Promise((resolve, reject) => {
  757. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
  758. new Error()
  759. )
  760. ctx.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => {
  761. ctx.DocumentUpdaterManager.checkDocument = sinon.stub().yields()
  762. ctx.WebsocketController.joinDoc(
  763. ctx.client,
  764. ctx.doc_id,
  765. -1,
  766. {},
  767. () => {}
  768. )
  769. cb()
  770. }
  771. ctx.WebsocketController.joinDoc(
  772. ctx.client,
  773. ctx.doc_id,
  774. -1,
  775. ctx.options,
  776. (...args) => {
  777. ctx.callback(...args)
  778. // make sure the other joinDoc request completed
  779. setTimeout(resolve, 5)
  780. }
  781. )
  782. })
  783. })
  784. it('should call the callback with an error', function (ctx) {
  785. expect(ctx.callback.called).to.equal(true)
  786. expect(ctx.callback.args[0][0].message).to.equal(
  787. 'joinLeaveEpoch mismatch'
  788. )
  789. })
  790. it('should get the document once (the parallel request wins)', function (ctx) {
  791. expect(ctx.DocumentUpdaterManager.getDocument.callCount).to.equal(1)
  792. })
  793. })
  794. describe('when the client starts a parallel leaveDoc request', function () {
  795. beforeEach(async function (ctx) {
  796. await new Promise((resolve, reject) => {
  797. ctx.RoomManager.leaveDoc = sinon.stub()
  798. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
  799. new Error()
  800. )
  801. ctx.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => {
  802. ctx.WebsocketController.leaveDoc(ctx.client, ctx.doc_id, () => {})
  803. cb()
  804. }
  805. ctx.WebsocketController.joinDoc(
  806. ctx.client,
  807. ctx.doc_id,
  808. -1,
  809. ctx.options,
  810. (...args) => {
  811. ctx.callback(...args)
  812. resolve()
  813. }
  814. )
  815. })
  816. })
  817. it('should call the callback with an error', function (ctx) {
  818. expect(ctx.callback.called).to.equal(true)
  819. expect(ctx.callback.args[0][0].message).to.equal(
  820. 'joinLeaveEpoch mismatch'
  821. )
  822. })
  823. it('should not get the document', function (ctx) {
  824. expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false)
  825. })
  826. })
  827. describe('when the client disconnects while RoomManager.joinDoc is running', function () {
  828. beforeEach(function (ctx) {
  829. ctx.RoomManager.joinDoc = (client, docId, cb) => {
  830. ctx.client.disconnected = true
  831. cb()
  832. }
  833. ctx.WebsocketController.joinDoc(
  834. ctx.client,
  835. ctx.doc_id,
  836. -1,
  837. ctx.options,
  838. ctx.callback
  839. )
  840. })
  841. it('should call the callback with no details', function (ctx) {
  842. expect(ctx.callback.args[0]).to.deep.equal([])
  843. })
  844. it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) {
  845. expect(
  846. ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
  847. status: 'after-joining-room',
  848. })
  849. ).to.equal(true)
  850. })
  851. it('should not get the document', function (ctx) {
  852. expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false)
  853. })
  854. })
  855. describe('when the client disconnects while DocumentUpdaterManager.getDocument is running', function () {
  856. beforeEach(function (ctx) {
  857. ctx.DocumentUpdaterManager.getDocument = (
  858. projectId,
  859. docId,
  860. fromVersion,
  861. callback
  862. ) => {
  863. ctx.client.disconnected = true
  864. return callback(null, {
  865. lines: ctx.doc_lines,
  866. version: ctx.version,
  867. ranges: ctx.ranges,
  868. ops: ctx.ops,
  869. })
  870. }
  871. ctx.WebsocketController.joinDoc(
  872. ctx.client,
  873. ctx.doc_id,
  874. -1,
  875. ctx.options,
  876. ctx.callback
  877. )
  878. })
  879. it('should call the callback with no details', function (ctx) {
  880. expect(ctx.callback.args[0]).to.deep.equal([])
  881. })
  882. it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) {
  883. expect(
  884. ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
  885. status: 'after-doc-updater-call',
  886. })
  887. ).to.equal(true)
  888. })
  889. })
  890. })
  891. describe('leaveDoc', function () {
  892. beforeEach(function (ctx) {
  893. ctx.doc_id = 'doc-id-123'
  894. ctx.client.ol_context.project_id = ctx.project_id
  895. ctx.RoomManager.leaveDoc = sinon.stub()
  896. ctx.WebsocketController.leaveDoc(ctx.client, ctx.doc_id, ctx.callback)
  897. })
  898. it('should inc the joinLeaveEpoch', function (ctx) {
  899. expect(ctx.client.joinLeaveEpoch).to.equal(1)
  900. })
  901. it('should remove the client from the doc_id room', function (ctx) {
  902. ctx.RoomManager.leaveDoc
  903. .calledWith(ctx.client, ctx.doc_id)
  904. .should.equal(true)
  905. })
  906. it('should call the callback', function (ctx) {
  907. ctx.callback.called.should.equal(true)
  908. })
  909. it('should increment the leave-doc metric', function (ctx) {
  910. ctx.metrics.inc.calledWith('editor.leave-doc').should.equal(true)
  911. })
  912. })
  913. describe('getConnectedUsers', function () {
  914. beforeEach(function (ctx) {
  915. ctx.client.ol_context.project_id = ctx.project_id
  916. ctx.users = ['mock', 'users']
  917. ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub()
  918. ctx.ConnectedUsersManager.getConnectedUsers = sinon
  919. .stub()
  920. .callsArgWith(1, null, ctx.users)
  921. })
  922. describe('when authorized', function () {
  923. beforeEach(async function (ctx) {
  924. await new Promise((resolve, reject) => {
  925. ctx.AuthorizationManager.assertClientCanViewProject = sinon
  926. .stub()
  927. .callsArgWith(1, null)
  928. ctx.WebsocketController.getConnectedUsers(ctx.client, (...args) => {
  929. ctx.callback(...Array.from(args || []))
  930. resolve()
  931. })
  932. })
  933. })
  934. it('should check that the client is authorized to view the project', function (ctx) {
  935. ctx.AuthorizationManager.assertClientCanViewProject
  936. .calledWith(ctx.client)
  937. .should.equal(true)
  938. })
  939. it('should broadcast a request to update the client list', function (ctx) {
  940. ctx.WebsocketLoadBalancer.emitToRoom
  941. .calledWith(ctx.project_id, 'clientTracking.refresh')
  942. .should.equal(true)
  943. })
  944. it('should get the connected users for the project', function (ctx) {
  945. ctx.ConnectedUsersManager.getConnectedUsers
  946. .calledWith(ctx.project_id)
  947. .should.equal(true)
  948. })
  949. it('should the users', function (ctx) {
  950. ctx.callback.calledWith(null, ctx.users).should.equal(true)
  951. })
  952. it('should increment the get-connected-users metric', function (ctx) {
  953. ctx.metrics.inc
  954. .calledWith('editor.get-connected-users')
  955. .should.equal(true)
  956. })
  957. })
  958. describe('when not authorized', function () {
  959. beforeEach(function (ctx) {
  960. ctx.AuthorizationManager.assertClientCanViewProject = sinon
  961. .stub()
  962. .callsArgWith(1, (ctx.err = new Error('not authorized')))
  963. ctx.WebsocketController.getConnectedUsers(ctx.client, ctx.callback)
  964. })
  965. it('should not get the connected users for the project', function (ctx) {
  966. ctx.ConnectedUsersManager.getConnectedUsers.called.should.equal(false)
  967. })
  968. it('should return an error', function (ctx) {
  969. ctx.callback.calledWith(ctx.err).should.equal(true)
  970. })
  971. })
  972. describe('when restricted user', function () {
  973. beforeEach(function (ctx) {
  974. ctx.client.ol_context.is_restricted_user = true
  975. ctx.AuthorizationManager.assertClientCanViewProject = sinon
  976. .stub()
  977. .callsArgWith(1, null)
  978. ctx.WebsocketController.getConnectedUsers(ctx.client, ctx.callback)
  979. })
  980. it('should return an empty array of users', function (ctx) {
  981. ctx.callback.calledWith(null, []).should.equal(true)
  982. })
  983. it('should not get the connected users for the project', function (ctx) {
  984. ctx.ConnectedUsersManager.getConnectedUsers.called.should.equal(false)
  985. })
  986. })
  987. describe('when the client has disconnected', function () {
  988. beforeEach(function (ctx) {
  989. ctx.client.disconnected = true
  990. ctx.AuthorizationManager.assertClientCanViewProject = sinon.stub()
  991. ctx.WebsocketController.getConnectedUsers(ctx.client, ctx.callback)
  992. })
  993. it('should call the callback with no details', function (ctx) {
  994. expect(ctx.callback.args[0]).to.deep.equal([])
  995. })
  996. it('should not check permissions', function (ctx) {
  997. expect(
  998. ctx.AuthorizationManager.assertClientCanViewProject.called
  999. ).to.equal(false)
  1000. })
  1001. })
  1002. })
  1003. describe('updateClientPosition', function () {
  1004. beforeEach(function (ctx) {
  1005. ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub()
  1006. ctx.ConnectedUsersManager.updateUserPosition = sinon
  1007. .stub()
  1008. .callsArgAsync(4)
  1009. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon
  1010. .stub()
  1011. .callsArgWith(2, null)
  1012. ctx.update = {
  1013. doc_id: (ctx.doc_id = 'doc-id-123'),
  1014. row: (ctx.row = 42),
  1015. column: (ctx.column = 37),
  1016. }
  1017. })
  1018. describe('with a logged in user', function () {
  1019. beforeEach(async function (ctx) {
  1020. await new Promise((resolve, reject) => {
  1021. ctx.client.ol_context = {
  1022. project_id: ctx.project_id,
  1023. first_name: (ctx.first_name = 'Douglas'),
  1024. last_name: (ctx.last_name = 'Adams'),
  1025. email: (ctx.email = 'joe@example.com'),
  1026. user_id: (ctx.user_id = 'user-id-123'),
  1027. }
  1028. ctx.populatedCursorData = {
  1029. doc_id: ctx.doc_id,
  1030. id: ctx.client.publicId,
  1031. name: `${ctx.first_name} ${ctx.last_name}`,
  1032. row: ctx.row,
  1033. column: ctx.column,
  1034. email: ctx.email,
  1035. user_id: ctx.user_id,
  1036. }
  1037. ctx.WebsocketController.updateClientPosition(
  1038. ctx.client,
  1039. ctx.update,
  1040. err => {
  1041. if (err) return reject(err)
  1042. resolve()
  1043. }
  1044. )
  1045. })
  1046. })
  1047. it("should send the update to the project room with the user's name", function (ctx) {
  1048. ctx.WebsocketLoadBalancer.emitToRoom
  1049. .calledWith(
  1050. ctx.project_id,
  1051. 'clientTracking.clientUpdated',
  1052. ctx.populatedCursorData
  1053. )
  1054. .should.equal(true)
  1055. })
  1056. it('should send the cursor data to the connected user manager', async function (ctx) {
  1057. await new Promise((resolve, reject) => {
  1058. ctx.ConnectedUsersManager.updateUserPosition
  1059. .calledWith(
  1060. ctx.project_id,
  1061. ctx.client.publicId,
  1062. {
  1063. _id: ctx.user_id,
  1064. email: ctx.email,
  1065. first_name: ctx.first_name,
  1066. last_name: ctx.last_name,
  1067. },
  1068. {
  1069. row: ctx.row,
  1070. column: ctx.column,
  1071. doc_id: ctx.doc_id,
  1072. }
  1073. )
  1074. .should.equal(true)
  1075. resolve()
  1076. })
  1077. })
  1078. it('should increment the update-client-position metric at 0.1 frequency', function (ctx) {
  1079. ctx.metrics.inc
  1080. .calledWith('editor.update-client-position', 0.1)
  1081. .should.equal(true)
  1082. })
  1083. })
  1084. describe('with a logged in user who has no last_name set', function () {
  1085. beforeEach(async function (ctx) {
  1086. await new Promise((resolve, reject) => {
  1087. ctx.client.ol_context = {
  1088. project_id: ctx.project_id,
  1089. first_name: (ctx.first_name = 'Douglas'),
  1090. last_name: undefined,
  1091. email: (ctx.email = 'joe@example.com'),
  1092. user_id: (ctx.user_id = 'user-id-123'),
  1093. }
  1094. ctx.populatedCursorData = {
  1095. doc_id: ctx.doc_id,
  1096. id: ctx.client.publicId,
  1097. name: `${ctx.first_name}`,
  1098. row: ctx.row,
  1099. column: ctx.column,
  1100. email: ctx.email,
  1101. user_id: ctx.user_id,
  1102. }
  1103. ctx.WebsocketController.updateClientPosition(
  1104. ctx.client,
  1105. ctx.update,
  1106. err => {
  1107. if (err) return reject(err)
  1108. resolve()
  1109. }
  1110. )
  1111. })
  1112. })
  1113. it("should send the update to the project room with the user's name", function (ctx) {
  1114. ctx.WebsocketLoadBalancer.emitToRoom
  1115. .calledWith(
  1116. ctx.project_id,
  1117. 'clientTracking.clientUpdated',
  1118. ctx.populatedCursorData
  1119. )
  1120. .should.equal(true)
  1121. })
  1122. it('should send the cursor data to the connected user manager', async function (ctx) {
  1123. await new Promise((resolve, reject) => {
  1124. ctx.ConnectedUsersManager.updateUserPosition
  1125. .calledWith(
  1126. ctx.project_id,
  1127. ctx.client.publicId,
  1128. {
  1129. _id: ctx.user_id,
  1130. email: ctx.email,
  1131. first_name: ctx.first_name,
  1132. last_name: undefined,
  1133. },
  1134. {
  1135. row: ctx.row,
  1136. column: ctx.column,
  1137. doc_id: ctx.doc_id,
  1138. }
  1139. )
  1140. .should.equal(true)
  1141. resolve()
  1142. })
  1143. })
  1144. it('should increment the update-client-position metric at 0.1 frequency', function (ctx) {
  1145. ctx.metrics.inc
  1146. .calledWith('editor.update-client-position', 0.1)
  1147. .should.equal(true)
  1148. })
  1149. })
  1150. describe('with a logged in user who has no first_name set', function () {
  1151. beforeEach(async function (ctx) {
  1152. await new Promise((resolve, reject) => {
  1153. ctx.client.ol_context = {
  1154. project_id: ctx.project_id,
  1155. first_name: undefined,
  1156. last_name: (ctx.last_name = 'Adams'),
  1157. email: (ctx.email = 'joe@example.com'),
  1158. user_id: (ctx.user_id = 'user-id-123'),
  1159. }
  1160. ctx.populatedCursorData = {
  1161. doc_id: ctx.doc_id,
  1162. id: ctx.client.publicId,
  1163. name: `${ctx.last_name}`,
  1164. row: ctx.row,
  1165. column: ctx.column,
  1166. email: ctx.email,
  1167. user_id: ctx.user_id,
  1168. }
  1169. ctx.WebsocketController.updateClientPosition(
  1170. ctx.client,
  1171. ctx.update,
  1172. err => {
  1173. if (err) return reject(err)
  1174. resolve()
  1175. }
  1176. )
  1177. })
  1178. })
  1179. it("should send the update to the project room with the user's name", function (ctx) {
  1180. ctx.WebsocketLoadBalancer.emitToRoom
  1181. .calledWith(
  1182. ctx.project_id,
  1183. 'clientTracking.clientUpdated',
  1184. ctx.populatedCursorData
  1185. )
  1186. .should.equal(true)
  1187. })
  1188. it('should send the cursor data to the connected user manager', async function (ctx) {
  1189. await new Promise((resolve, reject) => {
  1190. ctx.ConnectedUsersManager.updateUserPosition
  1191. .calledWith(
  1192. ctx.project_id,
  1193. ctx.client.publicId,
  1194. {
  1195. _id: ctx.user_id,
  1196. email: ctx.email,
  1197. first_name: undefined,
  1198. last_name: ctx.last_name,
  1199. },
  1200. {
  1201. row: ctx.row,
  1202. column: ctx.column,
  1203. doc_id: ctx.doc_id,
  1204. }
  1205. )
  1206. .should.equal(true)
  1207. resolve()
  1208. })
  1209. })
  1210. it('should increment the update-client-position metric at 0.1 frequency', function (ctx) {
  1211. ctx.metrics.inc
  1212. .calledWith('editor.update-client-position', 0.1)
  1213. .should.equal(true)
  1214. })
  1215. })
  1216. describe('with a logged in user who has no names set', function () {
  1217. beforeEach(async function (ctx) {
  1218. await new Promise((resolve, reject) => {
  1219. ctx.client.ol_context = {
  1220. project_id: ctx.project_id,
  1221. first_name: undefined,
  1222. last_name: undefined,
  1223. email: (ctx.email = 'joe@example.com'),
  1224. user_id: (ctx.user_id = 'user-id-123'),
  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 name 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. user_id: ctx.user_id,
  1242. name: '',
  1243. row: ctx.row,
  1244. column: ctx.column,
  1245. email: ctx.email,
  1246. })
  1247. .should.equal(true)
  1248. })
  1249. })
  1250. describe('with an anonymous user', function () {
  1251. beforeEach(async function (ctx) {
  1252. await new Promise((resolve, reject) => {
  1253. ctx.client.ol_context = {
  1254. project_id: ctx.project_id,
  1255. }
  1256. ctx.WebsocketController.updateClientPosition(
  1257. ctx.client,
  1258. ctx.update,
  1259. err => {
  1260. if (err) return reject(err)
  1261. resolve()
  1262. }
  1263. )
  1264. })
  1265. })
  1266. it('should send the update to the project room with no name', function (ctx) {
  1267. ctx.WebsocketLoadBalancer.emitToRoom
  1268. .calledWith(ctx.project_id, 'clientTracking.clientUpdated', {
  1269. doc_id: ctx.doc_id,
  1270. id: ctx.client.publicId,
  1271. name: '',
  1272. row: ctx.row,
  1273. column: ctx.column,
  1274. })
  1275. .should.equal(true)
  1276. })
  1277. it('should not send cursor data to the connected user manager', async function (ctx) {
  1278. await new Promise((resolve, reject) => {
  1279. ctx.ConnectedUsersManager.updateUserPosition.called.should.equal(
  1280. false
  1281. )
  1282. resolve()
  1283. })
  1284. })
  1285. })
  1286. describe('when the client has disconnected', function () {
  1287. beforeEach(async function (ctx) {
  1288. await new Promise((resolve, reject) => {
  1289. ctx.client.disconnected = true
  1290. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc =
  1291. sinon.stub()
  1292. ctx.WebsocketController.updateClientPosition(
  1293. ctx.client,
  1294. ctx.update,
  1295. (...args) => {
  1296. ctx.callback(...args)
  1297. if (args[0]) return reject(args[0])
  1298. resolve()
  1299. }
  1300. )
  1301. })
  1302. })
  1303. it('should call the callback with no details', function (ctx) {
  1304. expect(ctx.callback.args[0]).to.deep.equal([])
  1305. })
  1306. it('should not check permissions', function (ctx) {
  1307. expect(
  1308. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.called
  1309. ).to.equal(false)
  1310. })
  1311. })
  1312. })
  1313. describe('applyOtUpdate', function () {
  1314. beforeEach(function (ctx) {
  1315. ctx.update = { op: { p: 12, t: 'foo' } }
  1316. ctx.client.ol_context.user_id = ctx.user_id
  1317. ctx.client.ol_context.project_id = ctx.project_id
  1318. ctx.WebsocketController._assertClientCanApplyUpdate = sinon
  1319. .stub()
  1320. .yields()
  1321. ctx.DocumentUpdaterManager.queueChange = sinon.stub().callsArg(3)
  1322. })
  1323. describe('succesfully', function () {
  1324. beforeEach(function (ctx) {
  1325. ctx.WebsocketController.applyOtUpdate(
  1326. ctx.client,
  1327. ctx.doc_id,
  1328. ctx.update,
  1329. ctx.callback
  1330. )
  1331. })
  1332. it('should set the source of the update to the client id', function (ctx) {
  1333. ctx.update.meta.source.should.equal(ctx.client.publicId)
  1334. })
  1335. it('should set the user_id of the update to the user id', function (ctx) {
  1336. ctx.update.meta.user_id.should.equal(ctx.user_id)
  1337. })
  1338. it('should queue the update', function (ctx) {
  1339. ctx.DocumentUpdaterManager.queueChange
  1340. .calledWith(ctx.project_id, ctx.doc_id, ctx.update)
  1341. .should.equal(true)
  1342. })
  1343. it('should call the callback', function (ctx) {
  1344. ctx.callback.called.should.equal(true)
  1345. })
  1346. it('should increment the doc updates', function (ctx) {
  1347. ctx.metrics.inc.calledWith('editor.doc-update').should.equal(true)
  1348. })
  1349. })
  1350. describe('unsuccessfully', function () {
  1351. beforeEach(function (ctx) {
  1352. ctx.client.disconnect = sinon.stub()
  1353. ctx.DocumentUpdaterManager.queueChange = sinon
  1354. .stub()
  1355. .callsArgWith(3, (ctx.error = new Error('Something went wrong')))
  1356. ctx.WebsocketController.applyOtUpdate(
  1357. ctx.client,
  1358. ctx.doc_id,
  1359. ctx.update,
  1360. ctx.callback
  1361. )
  1362. })
  1363. it('should disconnect the client', function (ctx) {
  1364. ctx.client.disconnect.called.should.equal(true)
  1365. })
  1366. it('should not log an error', function (ctx) {
  1367. ctx.logger.error.called.should.equal(false)
  1368. })
  1369. it('should call the callback with the error', function (ctx) {
  1370. ctx.callback.calledWith(ctx.error).should.equal(true)
  1371. })
  1372. })
  1373. describe('when not authorized', function () {
  1374. beforeEach(function (ctx) {
  1375. ctx.client.disconnect = sinon.stub()
  1376. ctx.WebsocketController._assertClientCanApplyUpdate = sinon
  1377. .stub()
  1378. .yields((ctx.error = new Error('not authorized')))
  1379. ctx.WebsocketController.applyOtUpdate(
  1380. ctx.client,
  1381. ctx.doc_id,
  1382. ctx.update,
  1383. ctx.callback
  1384. )
  1385. })
  1386. // This happens in a setTimeout to allow the client a chance to receive the error first.
  1387. // I'm not sure how to unit test, but it is acceptance tested.
  1388. // it "should disconnect the client", ->
  1389. // @client.disconnect.called.should.equal true
  1390. it('should not log a warning', function (ctx) {
  1391. ctx.logger.warn.called.should.equal(false)
  1392. })
  1393. it('should call the callback with the error', function (ctx) {
  1394. ctx.callback.calledWith(ctx.error).should.equal(true)
  1395. })
  1396. })
  1397. describe('update_too_large', function () {
  1398. beforeEach(async function (ctx) {
  1399. await new Promise((resolve, reject) => {
  1400. ctx.client.disconnect = sinon.stub()
  1401. ctx.client.emit = sinon.stub()
  1402. ctx.client.ol_context.user_id = ctx.user_id
  1403. ctx.client.ol_context.project_id = ctx.project_id
  1404. const error = new UpdateTooLargeError(7372835)
  1405. ctx.DocumentUpdaterManager.queueChange = sinon
  1406. .stub()
  1407. .callsArgWith(3, error)
  1408. ctx.WebsocketController.applyOtUpdate(
  1409. ctx.client,
  1410. ctx.doc_id,
  1411. ctx.update,
  1412. ctx.callback
  1413. )
  1414. setTimeout(() => resolve(), 1)
  1415. })
  1416. })
  1417. it('should call the callback with no error', function (ctx) {
  1418. ctx.callback.called.should.equal(true)
  1419. ctx.callback.args[0].should.deep.equal([])
  1420. })
  1421. it('should log a warning with the size and context', function (ctx) {
  1422. ctx.logger.warn.called.should.equal(true)
  1423. ctx.logger.warn.args[0].should.deep.equal([
  1424. {
  1425. userId: ctx.user_id,
  1426. projectId: ctx.project_id,
  1427. docId: ctx.doc_id,
  1428. updateSize: 7372835,
  1429. },
  1430. 'update is too large',
  1431. ])
  1432. })
  1433. describe('after 100ms', function () {
  1434. beforeEach(async function () {
  1435. await new Promise(resolve => {
  1436. setTimeout(resolve, 100)
  1437. })
  1438. })
  1439. it('should send an otUpdateError the client', function (ctx) {
  1440. ctx.client.emit.calledWith('otUpdateError').should.equal(true)
  1441. })
  1442. it('should disconnect the client', function (ctx) {
  1443. ctx.client.disconnect.called.should.equal(true)
  1444. })
  1445. })
  1446. describe('when the client disconnects during the next 100ms', function () {
  1447. beforeEach(async function (ctx) {
  1448. await new Promise((resolve, reject) => {
  1449. ctx.client.disconnected = true
  1450. setTimeout(resolve, 100)
  1451. })
  1452. })
  1453. it('should not send an otUpdateError the client', function (ctx) {
  1454. ctx.client.emit.calledWith('otUpdateError').should.equal(false)
  1455. })
  1456. it('should not disconnect the client', function (ctx) {
  1457. ctx.client.disconnect.called.should.equal(false)
  1458. })
  1459. it('should increment the editor.doc-update.disconnected metric with a status', function (ctx) {
  1460. expect(
  1461. ctx.metrics.inc.calledWith('editor.doc-update.disconnected', 1, {
  1462. status: 'at-otUpdateError',
  1463. })
  1464. ).to.equal(true)
  1465. })
  1466. })
  1467. })
  1468. })
  1469. describe('_assertClientCanApplyUpdate', function () {
  1470. beforeEach(function (ctx) {
  1471. ctx.edit_update = {
  1472. op: [
  1473. { i: 'foo', p: 42 },
  1474. { c: 'bar', p: 132 },
  1475. ],
  1476. } // comments may still be in an edit op
  1477. ctx.comment_update = { op: [{ c: 'bar', p: 132 }] }
  1478. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc = sinon.stub()
  1479. ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc = sinon.stub()
  1480. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub()
  1481. })
  1482. describe('with a read-write client', function () {
  1483. it('should return successfully', async function (ctx) {
  1484. await new Promise((resolve, reject) => {
  1485. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(null)
  1486. ctx.WebsocketController._assertClientCanApplyUpdate(
  1487. ctx.client,
  1488. ctx.doc_id,
  1489. ctx.edit_update,
  1490. error => {
  1491. expect(error).to.be.null
  1492. resolve()
  1493. }
  1494. )
  1495. })
  1496. })
  1497. })
  1498. describe('with a read-only client and an edit op', function () {
  1499. it('should return an error', async function (ctx) {
  1500. await new Promise((resolve, reject) => {
  1501. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
  1502. new Error('not authorized')
  1503. )
  1504. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
  1505. ctx.WebsocketController._assertClientCanApplyUpdate(
  1506. ctx.client,
  1507. ctx.doc_id,
  1508. ctx.edit_update,
  1509. error => {
  1510. expect(error.message).to.equal('not authorized')
  1511. resolve()
  1512. }
  1513. )
  1514. })
  1515. })
  1516. })
  1517. describe('with a read-only client and a comment op', function () {
  1518. it('should return successfully', async function (ctx) {
  1519. await new Promise((resolve, reject) => {
  1520. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
  1521. new Error('not authorized')
  1522. )
  1523. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
  1524. ctx.WebsocketController._assertClientCanApplyUpdate(
  1525. ctx.client,
  1526. ctx.doc_id,
  1527. ctx.comment_update,
  1528. error => {
  1529. expect(error).to.be.null
  1530. resolve()
  1531. }
  1532. )
  1533. })
  1534. })
  1535. })
  1536. describe('with a totally unauthorized client', function () {
  1537. it('should return an error', async function (ctx) {
  1538. await new Promise((resolve, reject) => {
  1539. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
  1540. new Error('not authorized')
  1541. )
  1542. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
  1543. new Error('not authorized')
  1544. )
  1545. ctx.WebsocketController._assertClientCanApplyUpdate(
  1546. ctx.client,
  1547. ctx.doc_id,
  1548. ctx.comment_update,
  1549. error => {
  1550. expect(error.message).to.equal('not authorized')
  1551. resolve()
  1552. }
  1553. )
  1554. })
  1555. })
  1556. })
  1557. describe('with a review client', function () {
  1558. it('op with tc should succeed', async function (ctx) {
  1559. await new Promise((resolve, reject) => {
  1560. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
  1561. new Error('not authorized')
  1562. )
  1563. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
  1564. ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc.yields(
  1565. null
  1566. )
  1567. ctx.WebsocketController._assertClientCanApplyUpdate(
  1568. ctx.client,
  1569. ctx.doc_id,
  1570. { op: [{ p: 10, i: 'a' }], meta: { tc: '123456' } },
  1571. error => {
  1572. expect(error).to.be.null
  1573. resolve()
  1574. }
  1575. )
  1576. })
  1577. })
  1578. it('op without tc should fail', async function (ctx) {
  1579. await new Promise((resolve, reject) => {
  1580. ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
  1581. new Error('not authorized')
  1582. )
  1583. ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
  1584. ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc.yields(
  1585. null
  1586. )
  1587. ctx.WebsocketController._assertClientCanApplyUpdate(
  1588. ctx.client,
  1589. ctx.doc_id,
  1590. { op: [{ p: 10, i: 'a' }] },
  1591. error => {
  1592. expect(error.message).to.equal('not authorized')
  1593. resolve()
  1594. }
  1595. )
  1596. })
  1597. })
  1598. })
  1599. })
  1600. })