WebsocketControllerTests.js 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096
  1. /* eslint-disable
  2. camelcase,
  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. const chai = require('chai');
  16. const should = chai.should();
  17. const sinon = require("sinon");
  18. const {
  19. expect
  20. } = chai;
  21. const modulePath = "../../../app/js/WebsocketController.js";
  22. const SandboxedModule = require('sandboxed-module');
  23. const tk = require("timekeeper");
  24. describe('WebsocketController', function() {
  25. beforeEach(function() {
  26. tk.freeze(new Date());
  27. this.project_id = "project-id-123";
  28. this.user = {
  29. _id: (this.user_id = "user-id-123"),
  30. first_name: "James",
  31. last_name: "Allen",
  32. email: "james@example.com",
  33. signUpDate: new Date("2014-01-01"),
  34. loginCount: 42
  35. };
  36. this.callback = sinon.stub();
  37. this.client = {
  38. disconnected: false,
  39. id: (this.client_id = "mock-client-id-123"),
  40. publicId: `other-id-${Math.random()}`,
  41. ol_context: {},
  42. join: sinon.stub(),
  43. leave: sinon.stub()
  44. };
  45. return this.WebsocketController = SandboxedModule.require(modulePath, { requires: {
  46. "./WebApiManager": (this.WebApiManager = {}),
  47. "./AuthorizationManager": (this.AuthorizationManager = {}),
  48. "./DocumentUpdaterManager": (this.DocumentUpdaterManager = {}),
  49. "./ConnectedUsersManager": (this.ConnectedUsersManager = {}),
  50. "./WebsocketLoadBalancer": (this.WebsocketLoadBalancer = {}),
  51. "logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub(), warn: sinon.stub() }),
  52. "metrics-sharelatex": (this.metrics = {
  53. inc: sinon.stub(),
  54. set: sinon.stub()
  55. }),
  56. "./RoomManager": (this.RoomManager = {})
  57. }
  58. });});
  59. afterEach(function() { return tk.reset(); });
  60. describe("joinProject", function() {
  61. describe("when authorised", function() {
  62. beforeEach(function() {
  63. this.client.id = "mock-client-id";
  64. this.project = {
  65. name: "Test Project",
  66. owner: {
  67. _id: (this.owner_id = "mock-owner-id-123")
  68. }
  69. };
  70. this.privilegeLevel = "owner";
  71. this.ConnectedUsersManager.updateUserPosition = sinon.stub().callsArg(4);
  72. this.isRestrictedUser = true;
  73. this.WebApiManager.joinProject = sinon.stub().callsArgWith(2, null, this.project, this.privilegeLevel, this.isRestrictedUser);
  74. this.RoomManager.joinProject = sinon.stub().callsArg(2);
  75. return this.WebsocketController.joinProject(this.client, this.user, this.project_id, this.callback);
  76. });
  77. it("should load the project from web", function() {
  78. return this.WebApiManager.joinProject
  79. .calledWith(this.project_id, this.user)
  80. .should.equal(true);
  81. });
  82. it("should join the project room", function() {
  83. return this.RoomManager.joinProject.calledWith(this.client, this.project_id).should.equal(true);
  84. });
  85. it("should set the privilege level on the client", function() {
  86. return this.client.ol_context.privilege_level.should.equal(this.privilegeLevel);
  87. });
  88. it("should set the user's id on the client", function() {
  89. return this.client.ol_context.user_id.should.equal(this.user._id);
  90. });
  91. it("should set the user's email on the client", function() {
  92. return this.client.ol_context.email.should.equal(this.user.email);
  93. });
  94. it("should set the user's first_name on the client", function() {
  95. return this.client.ol_context.first_name.should.equal(this.user.first_name);
  96. });
  97. it("should set the user's last_name on the client", function() {
  98. return this.client.ol_context.last_name.should.equal(this.user.last_name);
  99. });
  100. it("should set the user's sign up date on the client", function() {
  101. return this.client.ol_context.signup_date.should.equal(this.user.signUpDate);
  102. });
  103. it("should set the user's login_count on the client", function() {
  104. return this.client.ol_context.login_count.should.equal(this.user.loginCount);
  105. });
  106. it("should set the connected time on the client", function() {
  107. return this.client.ol_context.connected_time.should.equal(new Date());
  108. });
  109. it("should set the project_id on the client", function() {
  110. return this.client.ol_context.project_id.should.equal(this.project_id);
  111. });
  112. it("should set the project owner id on the client", function() {
  113. return this.client.ol_context.owner_id.should.equal(this.owner_id);
  114. });
  115. it("should set the is_restricted_user flag on the client", function() {
  116. return this.client.ol_context.is_restricted_user.should.equal(this.isRestrictedUser);
  117. });
  118. it("should call the callback with the project, privilegeLevel and protocolVersion", function() {
  119. return this.callback
  120. .calledWith(null, this.project, this.privilegeLevel, this.WebsocketController.PROTOCOL_VERSION)
  121. .should.equal(true);
  122. });
  123. it("should mark the user as connected in ConnectedUsersManager", function() {
  124. return this.ConnectedUsersManager.updateUserPosition
  125. .calledWith(this.project_id, this.client.publicId, this.user, null)
  126. .should.equal(true);
  127. });
  128. return it("should increment the join-project metric", function() {
  129. return this.metrics.inc.calledWith("editor.join-project").should.equal(true);
  130. });
  131. });
  132. describe("when not authorized", function() {
  133. beforeEach(function() {
  134. this.WebApiManager.joinProject = sinon.stub().callsArgWith(2, null, null, null);
  135. return this.WebsocketController.joinProject(this.client, this.user, this.project_id, this.callback);
  136. });
  137. it("should return an error", function() {
  138. return this.callback
  139. .calledWith(sinon.match({message: "not authorized"}))
  140. .should.equal(true);
  141. });
  142. return it("should not log an error", function() {
  143. return this.logger.error.called.should.equal(false);
  144. });
  145. });
  146. describe("when the subscribe failed", function() {
  147. beforeEach(function() {
  148. this.client.id = "mock-client-id";
  149. this.project = {
  150. name: "Test Project",
  151. owner: {
  152. _id: (this.owner_id = "mock-owner-id-123")
  153. }
  154. };
  155. this.privilegeLevel = "owner";
  156. this.ConnectedUsersManager.updateUserPosition = sinon.stub().callsArg(4);
  157. this.isRestrictedUser = true;
  158. this.WebApiManager.joinProject = sinon.stub().callsArgWith(2, null, this.project, this.privilegeLevel, this.isRestrictedUser);
  159. this.RoomManager.joinProject = sinon.stub().callsArgWith(2, new Error("subscribe failed"));
  160. return this.WebsocketController.joinProject(this.client, this.user, this.project_id, this.callback);
  161. });
  162. return it("should return an error", function() {
  163. this.callback
  164. .calledWith(sinon.match({message: "subscribe failed"}))
  165. .should.equal(true);
  166. return this.callback.args[0][0].message.should.equal("subscribe failed");
  167. });
  168. });
  169. describe("when the client has disconnected", function() {
  170. beforeEach(function() {
  171. this.client.disconnected = true;
  172. this.WebApiManager.joinProject = sinon.stub().callsArg(2);
  173. return this.WebsocketController.joinProject(this.client, this.user, this.project_id, this.callback);
  174. });
  175. it("should not call WebApiManager.joinProject", function() {
  176. return expect(this.WebApiManager.joinProject.called).to.equal(false);
  177. });
  178. it("should call the callback with no details", function() {
  179. return expect(this.callback.args[0]).to.deep.equal([]);
  180. });
  181. return it("should increment the editor.join-project.disconnected metric with a status", function() {
  182. return expect(this.metrics.inc.calledWith('editor.join-project.disconnected', 1, {status: 'immediately'})).to.equal(true);
  183. });
  184. });
  185. return describe("when the client disconnects while WebApiManager.joinProject is running", function() {
  186. beforeEach(function() {
  187. this.WebApiManager.joinProject = (project, user, cb) => {
  188. this.client.disconnected = true;
  189. return cb(null, this.project, this.privilegeLevel, this.isRestrictedUser);
  190. };
  191. return this.WebsocketController.joinProject(this.client, this.user, this.project_id, this.callback);
  192. });
  193. it("should call the callback with no details", function() {
  194. return expect(this.callback.args[0]).to.deep.equal([]);
  195. });
  196. return it("should increment the editor.join-project.disconnected metric with a status", function() {
  197. return expect(this.metrics.inc.calledWith('editor.join-project.disconnected', 1, {status: 'after-web-api-call'})).to.equal(true);
  198. });
  199. });
  200. });
  201. describe("leaveProject", function() {
  202. beforeEach(function() {
  203. this.DocumentUpdaterManager.flushProjectToMongoAndDelete = sinon.stub().callsArg(1);
  204. this.ConnectedUsersManager.markUserAsDisconnected = sinon.stub().callsArg(2);
  205. this.WebsocketLoadBalancer.emitToRoom = sinon.stub();
  206. this.RoomManager.leaveProjectAndDocs = sinon.stub();
  207. this.clientsInRoom = [];
  208. this.io = {
  209. sockets: {
  210. clients: room_id => {
  211. if (room_id !== this.project_id) {
  212. throw "expected room_id to be project_id";
  213. }
  214. return this.clientsInRoom;
  215. }
  216. }
  217. };
  218. this.client.ol_context.project_id = this.project_id;
  219. this.client.ol_context.user_id = this.user_id;
  220. this.WebsocketController.FLUSH_IF_EMPTY_DELAY = 0;
  221. return tk.reset();
  222. }); // Allow setTimeout to work.
  223. describe("when the client did not joined a project yet", function() {
  224. beforeEach(function(done) {
  225. this.client.ol_context = {};
  226. return this.WebsocketController.leaveProject(this.io, this.client, done);
  227. });
  228. it("should bail out when calling leaveProject", function() {
  229. this.WebsocketLoadBalancer.emitToRoom.called.should.equal(false);
  230. this.RoomManager.leaveProjectAndDocs.called.should.equal(false);
  231. return this.ConnectedUsersManager.markUserAsDisconnected.called.should.equal(false);
  232. });
  233. return it("should not inc any metric", function() {
  234. return this.metrics.inc.called.should.equal(false);
  235. });
  236. });
  237. describe("when the project is empty", function() {
  238. beforeEach(function(done) {
  239. this.clientsInRoom = [];
  240. return this.WebsocketController.leaveProject(this.io, this.client, done);
  241. });
  242. it("should end clientTracking.clientDisconnected to the project room", function() {
  243. return this.WebsocketLoadBalancer.emitToRoom
  244. .calledWith(this.project_id, "clientTracking.clientDisconnected", this.client.publicId)
  245. .should.equal(true);
  246. });
  247. it("should mark the user as disconnected", function() {
  248. return this.ConnectedUsersManager.markUserAsDisconnected
  249. .calledWith(this.project_id, this.client.publicId)
  250. .should.equal(true);
  251. });
  252. it("should flush the project in the document updater", function() {
  253. return this.DocumentUpdaterManager.flushProjectToMongoAndDelete
  254. .calledWith(this.project_id)
  255. .should.equal(true);
  256. });
  257. it("should increment the leave-project metric", function() {
  258. return this.metrics.inc.calledWith("editor.leave-project").should.equal(true);
  259. });
  260. return it("should track the disconnection in RoomManager", function() {
  261. return this.RoomManager.leaveProjectAndDocs
  262. .calledWith(this.client)
  263. .should.equal(true);
  264. });
  265. });
  266. describe("when the project is not empty", function() {
  267. beforeEach(function() {
  268. this.clientsInRoom = ["mock-remaining-client"];
  269. return this.WebsocketController.leaveProject(this.io, this.client);
  270. });
  271. return it("should not flush the project in the document updater", function() {
  272. return this.DocumentUpdaterManager.flushProjectToMongoAndDelete
  273. .called.should.equal(false);
  274. });
  275. });
  276. describe("when client has not authenticated", function() {
  277. beforeEach(function(done) {
  278. this.client.ol_context.user_id = null;
  279. this.client.ol_context.project_id = null;
  280. return this.WebsocketController.leaveProject(this.io, this.client, done);
  281. });
  282. it("should not end clientTracking.clientDisconnected to the project room", function() {
  283. return this.WebsocketLoadBalancer.emitToRoom
  284. .calledWith(this.project_id, "clientTracking.clientDisconnected", this.client.publicId)
  285. .should.equal(false);
  286. });
  287. it("should not mark the user as disconnected", function() {
  288. return this.ConnectedUsersManager.markUserAsDisconnected
  289. .calledWith(this.project_id, this.client.publicId)
  290. .should.equal(false);
  291. });
  292. it("should not flush the project in the document updater", function() {
  293. return this.DocumentUpdaterManager.flushProjectToMongoAndDelete
  294. .calledWith(this.project_id)
  295. .should.equal(false);
  296. });
  297. return it("should not increment the leave-project metric", function() {
  298. return this.metrics.inc.calledWith("editor.leave-project").should.equal(false);
  299. });
  300. });
  301. return describe("when client has not joined a project", function() {
  302. beforeEach(function(done) {
  303. this.client.ol_context.user_id = this.user_id;
  304. this.client.ol_context.project_id = null;
  305. return this.WebsocketController.leaveProject(this.io, this.client, done);
  306. });
  307. it("should not end clientTracking.clientDisconnected to the project room", function() {
  308. return this.WebsocketLoadBalancer.emitToRoom
  309. .calledWith(this.project_id, "clientTracking.clientDisconnected", this.client.publicId)
  310. .should.equal(false);
  311. });
  312. it("should not mark the user as disconnected", function() {
  313. return this.ConnectedUsersManager.markUserAsDisconnected
  314. .calledWith(this.project_id, this.client.publicId)
  315. .should.equal(false);
  316. });
  317. it("should not flush the project in the document updater", function() {
  318. return this.DocumentUpdaterManager.flushProjectToMongoAndDelete
  319. .calledWith(this.project_id)
  320. .should.equal(false);
  321. });
  322. return it("should not increment the leave-project metric", function() {
  323. return this.metrics.inc.calledWith("editor.leave-project").should.equal(false);
  324. });
  325. });
  326. });
  327. describe("joinDoc", function() {
  328. beforeEach(function() {
  329. this.doc_id = "doc-id-123";
  330. this.doc_lines = ["doc", "lines"];
  331. this.version = 42;
  332. this.ops = ["mock", "ops"];
  333. this.ranges = { "mock": "ranges" };
  334. this.options = {};
  335. this.client.ol_context.project_id = this.project_id;
  336. this.client.ol_context.is_restricted_user = false;
  337. this.AuthorizationManager.addAccessToDoc = sinon.stub();
  338. this.AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, null);
  339. this.DocumentUpdaterManager.getDocument = sinon.stub().callsArgWith(3, null, this.doc_lines, this.version, this.ranges, this.ops);
  340. return this.RoomManager.joinDoc = sinon.stub().callsArg(2);
  341. });
  342. describe("works", function() {
  343. beforeEach(function() {
  344. return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
  345. });
  346. it("should check that the client is authorized to view the project", function() {
  347. return this.AuthorizationManager.assertClientCanViewProject
  348. .calledWith(this.client)
  349. .should.equal(true);
  350. });
  351. it("should get the document from the DocumentUpdaterManager with fromVersion", function() {
  352. return this.DocumentUpdaterManager.getDocument
  353. .calledWith(this.project_id, this.doc_id, -1)
  354. .should.equal(true);
  355. });
  356. it("should add permissions for the client to access the doc", function() {
  357. return this.AuthorizationManager.addAccessToDoc
  358. .calledWith(this.client, this.doc_id)
  359. .should.equal(true);
  360. });
  361. it("should join the client to room for the doc_id", function() {
  362. return this.RoomManager.joinDoc
  363. .calledWith(this.client, this.doc_id)
  364. .should.equal(true);
  365. });
  366. it("should call the callback with the lines, version, ranges and ops", function() {
  367. return this.callback
  368. .calledWith(null, this.doc_lines, this.version, this.ops, this.ranges)
  369. .should.equal(true);
  370. });
  371. return it("should increment the join-doc metric", function() {
  372. return this.metrics.inc.calledWith("editor.join-doc").should.equal(true);
  373. });
  374. });
  375. describe("with a fromVersion", function() {
  376. beforeEach(function() {
  377. this.fromVersion = 40;
  378. return this.WebsocketController.joinDoc(this.client, this.doc_id, this.fromVersion, this.options, this.callback);
  379. });
  380. return it("should get the document from the DocumentUpdaterManager with fromVersion", function() {
  381. return this.DocumentUpdaterManager.getDocument
  382. .calledWith(this.project_id, this.doc_id, this.fromVersion)
  383. .should.equal(true);
  384. });
  385. });
  386. describe("with doclines that need escaping", function() {
  387. beforeEach(function() {
  388. this.doc_lines.push(["räksmörgås"]);
  389. return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
  390. });
  391. return it("should call the callback with the escaped lines", function() {
  392. const escaped_lines = this.callback.args[0][1];
  393. const escaped_word = escaped_lines.pop();
  394. escaped_word.should.equal('räksmörgås');
  395. // Check that unescaping works
  396. return decodeURIComponent(escape(escaped_word)).should.equal("räksmörgås");
  397. });
  398. });
  399. describe("with comments that need encoding", function() {
  400. beforeEach(function() {
  401. this.ranges.comments = [{ op: { c: "räksmörgås" } }];
  402. return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, { encodeRanges: true }, this.callback);
  403. });
  404. return it("should call the callback with the encoded comment", function() {
  405. const encoded_comments = this.callback.args[0][4];
  406. const encoded_comment = encoded_comments.comments.pop();
  407. const encoded_comment_text = encoded_comment.op.c;
  408. return encoded_comment_text.should.equal('räksmörgås');
  409. });
  410. });
  411. describe("with changes that need encoding", function() {
  412. it("should call the callback with the encoded insert change", function() {
  413. this.ranges.changes = [{ op: { i: "räksmörgås" } }];
  414. this.WebsocketController.joinDoc(this.client, this.doc_id, -1, { encodeRanges: true }, this.callback);
  415. const encoded_changes = this.callback.args[0][4];
  416. const encoded_change = encoded_changes.changes.pop();
  417. const encoded_change_text = encoded_change.op.i;
  418. return encoded_change_text.should.equal('räksmörgås');
  419. });
  420. return it("should call the callback with the encoded delete change", function() {
  421. this.ranges.changes = [{ op: { d: "räksmörgås" } }];
  422. this.WebsocketController.joinDoc(this.client, this.doc_id, -1, { encodeRanges: true }, this.callback);
  423. const encoded_changes = this.callback.args[0][4];
  424. const encoded_change = encoded_changes.changes.pop();
  425. const encoded_change_text = encoded_change.op.d;
  426. return encoded_change_text.should.equal('räksmörgås');
  427. });
  428. });
  429. describe("when not authorized", function() {
  430. beforeEach(function() {
  431. this.AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, (this.err = new Error("not authorized")));
  432. return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
  433. });
  434. it("should call the callback with an error", function() {
  435. return this.callback.calledWith(sinon.match({message: "not authorized"})).should.equal(true);
  436. });
  437. return it("should not call the DocumentUpdaterManager", function() {
  438. return this.DocumentUpdaterManager.getDocument.called.should.equal(false);
  439. });
  440. });
  441. describe("with a restricted client", function() {
  442. beforeEach(function() {
  443. this.ranges.comments = [{op: {a: 1}}, {op: {a: 2}}];
  444. this.client.ol_context.is_restricted_user = true;
  445. return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
  446. });
  447. return it("should overwrite ranges.comments with an empty list", function() {
  448. const ranges = this.callback.args[0][4];
  449. return expect(ranges.comments).to.deep.equal([]);
  450. });
  451. });
  452. describe("when the client has disconnected", function() {
  453. beforeEach(function() {
  454. this.client.disconnected = true;
  455. return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
  456. });
  457. it("should call the callback with no details", function() {
  458. return expect(this.callback.args[0]).to.deep.equal([]);
  459. });
  460. it("should increment the editor.join-doc.disconnected metric with a status", function() {
  461. return expect(this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {status: 'immediately'})).to.equal(true);
  462. });
  463. return it("should not get the document", function() {
  464. return expect(this.DocumentUpdaterManager.getDocument.called).to.equal(false);
  465. });
  466. });
  467. describe("when the client disconnects while RoomManager.joinDoc is running", function() {
  468. beforeEach(function() {
  469. this.RoomManager.joinDoc = (client, doc_id, cb) => {
  470. this.client.disconnected = true;
  471. return cb();
  472. };
  473. return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
  474. });
  475. it("should call the callback with no details", function() {
  476. return expect(this.callback.args[0]).to.deep.equal([]);
  477. });
  478. it("should increment the editor.join-doc.disconnected metric with a status", function() {
  479. return expect(this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {status: 'after-joining-room'})).to.equal(true);
  480. });
  481. return it("should not get the document", function() {
  482. return expect(this.DocumentUpdaterManager.getDocument.called).to.equal(false);
  483. });
  484. });
  485. return describe("when the client disconnects while DocumentUpdaterManager.getDocument is running", function() {
  486. beforeEach(function() {
  487. this.DocumentUpdaterManager.getDocument = (project_id, doc_id, fromVersion, callback) => {
  488. this.client.disconnected = true;
  489. return callback(null, this.doc_lines, this.version, this.ranges, this.ops);
  490. };
  491. return this.WebsocketController.joinDoc(this.client, this.doc_id, -1, this.options, this.callback);
  492. });
  493. it("should call the callback with no details", function() {
  494. return expect(this.callback.args[0]).to.deep.equal([]);
  495. });
  496. return it("should increment the editor.join-doc.disconnected metric with a status", function() {
  497. return expect(this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {status: 'after-doc-updater-call'})).to.equal(true);
  498. });
  499. });
  500. });
  501. describe("leaveDoc", function() {
  502. beforeEach(function() {
  503. this.doc_id = "doc-id-123";
  504. this.client.ol_context.project_id = this.project_id;
  505. this.RoomManager.leaveDoc = sinon.stub();
  506. return this.WebsocketController.leaveDoc(this.client, this.doc_id, this.callback);
  507. });
  508. it("should remove the client from the doc_id room", function() {
  509. return this.RoomManager.leaveDoc
  510. .calledWith(this.client, this.doc_id).should.equal(true);
  511. });
  512. it("should call the callback", function() {
  513. return this.callback.called.should.equal(true);
  514. });
  515. return it("should increment the leave-doc metric", function() {
  516. return this.metrics.inc.calledWith("editor.leave-doc").should.equal(true);
  517. });
  518. });
  519. describe("getConnectedUsers", function() {
  520. beforeEach(function() {
  521. this.client.ol_context.project_id = this.project_id;
  522. this.users = ["mock", "users"];
  523. this.WebsocketLoadBalancer.emitToRoom = sinon.stub();
  524. return this.ConnectedUsersManager.getConnectedUsers = sinon.stub().callsArgWith(1, null, this.users);
  525. });
  526. describe("when authorized", function() {
  527. beforeEach(function(done) {
  528. this.AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, null);
  529. return this.WebsocketController.getConnectedUsers(this.client, (...args) => {
  530. this.callback(...Array.from(args || []));
  531. return done();
  532. });
  533. });
  534. it("should check that the client is authorized to view the project", function() {
  535. return this.AuthorizationManager.assertClientCanViewProject
  536. .calledWith(this.client)
  537. .should.equal(true);
  538. });
  539. it("should broadcast a request to update the client list", function() {
  540. return this.WebsocketLoadBalancer.emitToRoom
  541. .calledWith(this.project_id, "clientTracking.refresh")
  542. .should.equal(true);
  543. });
  544. it("should get the connected users for the project", function() {
  545. return this.ConnectedUsersManager.getConnectedUsers
  546. .calledWith(this.project_id)
  547. .should.equal(true);
  548. });
  549. it("should return the users", function() {
  550. return this.callback.calledWith(null, this.users).should.equal(true);
  551. });
  552. return it("should increment the get-connected-users metric", function() {
  553. return this.metrics.inc.calledWith("editor.get-connected-users").should.equal(true);
  554. });
  555. });
  556. describe("when not authorized", function() {
  557. beforeEach(function() {
  558. this.AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, (this.err = new Error("not authorized")));
  559. return this.WebsocketController.getConnectedUsers(this.client, this.callback);
  560. });
  561. it("should not get the connected users for the project", function() {
  562. return this.ConnectedUsersManager.getConnectedUsers
  563. .called
  564. .should.equal(false);
  565. });
  566. return it("should return an error", function() {
  567. return this.callback.calledWith(this.err).should.equal(true);
  568. });
  569. });
  570. describe("when restricted user", function() {
  571. beforeEach(function() {
  572. this.client.ol_context.is_restricted_user = true;
  573. this.AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, null);
  574. return this.WebsocketController.getConnectedUsers(this.client, this.callback);
  575. });
  576. it("should return an empty array of users", function() {
  577. return this.callback.calledWith(null, []).should.equal(true);
  578. });
  579. return it("should not get the connected users for the project", function() {
  580. return this.ConnectedUsersManager.getConnectedUsers
  581. .called
  582. .should.equal(false);
  583. });
  584. });
  585. return describe("when the client has disconnected", function() {
  586. beforeEach(function() {
  587. this.client.disconnected = true;
  588. this.AuthorizationManager.assertClientCanViewProject = sinon.stub();
  589. return this.WebsocketController.getConnectedUsers(this.client, this.callback);
  590. });
  591. it("should call the callback with no details", function() {
  592. return expect(this.callback.args[0]).to.deep.equal([]);
  593. });
  594. return it("should not check permissions", function() {
  595. return expect(this.AuthorizationManager.assertClientCanViewProject.called).to.equal(false);
  596. });
  597. });
  598. });
  599. describe("updateClientPosition", function() {
  600. beforeEach(function() {
  601. this.WebsocketLoadBalancer.emitToRoom = sinon.stub();
  602. this.ConnectedUsersManager.updateUserPosition = sinon.stub().callsArgWith(4);
  603. this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub().callsArgWith(2, null);
  604. return this.update = {
  605. doc_id: (this.doc_id = "doc-id-123"),
  606. row: (this.row = 42),
  607. column: (this.column = 37)
  608. };});
  609. describe("with a logged in user", function() {
  610. beforeEach(function() {
  611. this.client.ol_context = {
  612. project_id: this.project_id,
  613. first_name: (this.first_name = "Douglas"),
  614. last_name: (this.last_name = "Adams"),
  615. email: (this.email = "joe@example.com"),
  616. user_id: (this.user_id = "user-id-123")
  617. };
  618. this.WebsocketController.updateClientPosition(this.client, this.update);
  619. return this.populatedCursorData = {
  620. doc_id: this.doc_id,
  621. id: this.client.publicId,
  622. name: `${this.first_name} ${this.last_name}`,
  623. row: this.row,
  624. column: this.column,
  625. email: this.email,
  626. user_id: this.user_id
  627. };
  628. });
  629. it("should send the update to the project room with the user's name", function() {
  630. return this.WebsocketLoadBalancer.emitToRoom.calledWith(this.project_id, "clientTracking.clientUpdated", this.populatedCursorData).should.equal(true);
  631. });
  632. it("should send the cursor data to the connected user manager", function(done){
  633. this.ConnectedUsersManager.updateUserPosition.calledWith(this.project_id, this.client.publicId, {
  634. _id: this.user_id,
  635. email: this.email,
  636. first_name: this.first_name,
  637. last_name: this.last_name
  638. }, {
  639. row: this.row,
  640. column: this.column,
  641. doc_id: this.doc_id
  642. }).should.equal(true);
  643. return done();
  644. });
  645. return it("should increment the update-client-position metric at 0.1 frequency", function() {
  646. return this.metrics.inc.calledWith("editor.update-client-position", 0.1).should.equal(true);
  647. });
  648. });
  649. describe("with a logged in user who has no last_name set", function() {
  650. beforeEach(function() {
  651. this.client.ol_context = {
  652. project_id: this.project_id,
  653. first_name: (this.first_name = "Douglas"),
  654. last_name: undefined,
  655. email: (this.email = "joe@example.com"),
  656. user_id: (this.user_id = "user-id-123")
  657. };
  658. this.WebsocketController.updateClientPosition(this.client, this.update);
  659. return this.populatedCursorData = {
  660. doc_id: this.doc_id,
  661. id: this.client.publicId,
  662. name: `${this.first_name}`,
  663. row: this.row,
  664. column: this.column,
  665. email: this.email,
  666. user_id: this.user_id
  667. };
  668. });
  669. it("should send the update to the project room with the user's name", function() {
  670. return this.WebsocketLoadBalancer.emitToRoom.calledWith(this.project_id, "clientTracking.clientUpdated", this.populatedCursorData).should.equal(true);
  671. });
  672. it("should send the cursor data to the connected user manager", function(done){
  673. this.ConnectedUsersManager.updateUserPosition.calledWith(this.project_id, this.client.publicId, {
  674. _id: this.user_id,
  675. email: this.email,
  676. first_name: this.first_name,
  677. last_name: undefined
  678. }, {
  679. row: this.row,
  680. column: this.column,
  681. doc_id: this.doc_id
  682. }).should.equal(true);
  683. return done();
  684. });
  685. return it("should increment the update-client-position metric at 0.1 frequency", function() {
  686. return this.metrics.inc.calledWith("editor.update-client-position", 0.1).should.equal(true);
  687. });
  688. });
  689. describe("with a logged in user who has no first_name set", function() {
  690. beforeEach(function() {
  691. this.client.ol_context = {
  692. project_id: this.project_id,
  693. first_name: undefined,
  694. last_name: (this.last_name = "Adams"),
  695. email: (this.email = "joe@example.com"),
  696. user_id: (this.user_id = "user-id-123")
  697. };
  698. this.WebsocketController.updateClientPosition(this.client, this.update);
  699. return this.populatedCursorData = {
  700. doc_id: this.doc_id,
  701. id: this.client.publicId,
  702. name: `${this.last_name}`,
  703. row: this.row,
  704. column: this.column,
  705. email: this.email,
  706. user_id: this.user_id
  707. };
  708. });
  709. it("should send the update to the project room with the user's name", function() {
  710. return this.WebsocketLoadBalancer.emitToRoom.calledWith(this.project_id, "clientTracking.clientUpdated", this.populatedCursorData).should.equal(true);
  711. });
  712. it("should send the cursor data to the connected user manager", function(done){
  713. this.ConnectedUsersManager.updateUserPosition.calledWith(this.project_id, this.client.publicId, {
  714. _id: this.user_id,
  715. email: this.email,
  716. first_name: undefined,
  717. last_name: this.last_name
  718. }, {
  719. row: this.row,
  720. column: this.column,
  721. doc_id: this.doc_id
  722. }).should.equal(true);
  723. return done();
  724. });
  725. return it("should increment the update-client-position metric at 0.1 frequency", function() {
  726. return this.metrics.inc.calledWith("editor.update-client-position", 0.1).should.equal(true);
  727. });
  728. });
  729. describe("with a logged in user who has no names set", function() {
  730. beforeEach(function() {
  731. this.client.ol_context = {
  732. project_id: this.project_id,
  733. first_name: undefined,
  734. last_name: undefined,
  735. email: (this.email = "joe@example.com"),
  736. user_id: (this.user_id = "user-id-123")
  737. };
  738. return this.WebsocketController.updateClientPosition(this.client, this.update);
  739. });
  740. return it("should send the update to the project name with no name", function() {
  741. return this.WebsocketLoadBalancer.emitToRoom
  742. .calledWith(this.project_id, "clientTracking.clientUpdated", {
  743. doc_id: this.doc_id,
  744. id: this.client.publicId,
  745. user_id: this.user_id,
  746. name: "",
  747. row: this.row,
  748. column: this.column,
  749. email: this.email
  750. })
  751. .should.equal(true);
  752. });
  753. });
  754. describe("with an anonymous user", function() {
  755. beforeEach(function() {
  756. this.client.ol_context = {
  757. project_id: this.project_id
  758. };
  759. return this.WebsocketController.updateClientPosition(this.client, this.update);
  760. });
  761. it("should send the update to the project room with no name", function() {
  762. return this.WebsocketLoadBalancer.emitToRoom
  763. .calledWith(this.project_id, "clientTracking.clientUpdated", {
  764. doc_id: this.doc_id,
  765. id: this.client.publicId,
  766. name: "",
  767. row: this.row,
  768. column: this.column
  769. })
  770. .should.equal(true);
  771. });
  772. return it("should not send cursor data to the connected user manager", function(done){
  773. this.ConnectedUsersManager.updateUserPosition.called.should.equal(false);
  774. return done();
  775. });
  776. });
  777. return describe("when the client has disconnected", function() {
  778. beforeEach(function() {
  779. this.client.disconnected = true;
  780. this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub();
  781. return this.WebsocketController.updateClientPosition(this.client, this.update, this.callback);
  782. });
  783. it("should call the callback with no details", function() {
  784. return expect(this.callback.args[0]).to.deep.equal([]);
  785. });
  786. return it("should not check permissions", function() {
  787. return expect(this.AuthorizationManager.assertClientCanViewProjectAndDoc.called).to.equal(false);
  788. });
  789. });
  790. });
  791. describe("applyOtUpdate", function() {
  792. beforeEach(function() {
  793. this.update = {op: {p: 12, t: "foo"}};
  794. this.client.ol_context.user_id = this.user_id;
  795. this.client.ol_context.project_id = this.project_id;
  796. this.WebsocketController._assertClientCanApplyUpdate = sinon.stub().yields();
  797. return this.DocumentUpdaterManager.queueChange = sinon.stub().callsArg(3);
  798. });
  799. describe("succesfully", function() {
  800. beforeEach(function() {
  801. return this.WebsocketController.applyOtUpdate(this.client, this.doc_id, this.update, this.callback);
  802. });
  803. it("should set the source of the update to the client id", function() {
  804. return this.update.meta.source.should.equal(this.client.publicId);
  805. });
  806. it("should set the user_id of the update to the user id", function() {
  807. return this.update.meta.user_id.should.equal(this.user_id);
  808. });
  809. it("should queue the update", function() {
  810. return this.DocumentUpdaterManager.queueChange
  811. .calledWith(this.project_id, this.doc_id, this.update)
  812. .should.equal(true);
  813. });
  814. it("should call the callback", function() {
  815. return this.callback.called.should.equal(true);
  816. });
  817. return it("should increment the doc updates", function() {
  818. return this.metrics.inc.calledWith("editor.doc-update").should.equal(true);
  819. });
  820. });
  821. describe("unsuccessfully", function() {
  822. beforeEach(function() {
  823. this.client.disconnect = sinon.stub();
  824. this.DocumentUpdaterManager.queueChange = sinon.stub().callsArgWith(3, (this.error = new Error("Something went wrong")));
  825. return this.WebsocketController.applyOtUpdate(this.client, this.doc_id, this.update, this.callback);
  826. });
  827. it("should disconnect the client", function() {
  828. return this.client.disconnect.called.should.equal(true);
  829. });
  830. it("should log an error", function() {
  831. return this.logger.error.called.should.equal(true);
  832. });
  833. return it("should call the callback with the error", function() {
  834. return this.callback.calledWith(this.error).should.equal(true);
  835. });
  836. });
  837. describe("when not authorized", function() {
  838. beforeEach(function() {
  839. this.client.disconnect = sinon.stub();
  840. this.WebsocketController._assertClientCanApplyUpdate = sinon.stub().yields(this.error = new Error("not authorized"));
  841. return this.WebsocketController.applyOtUpdate(this.client, this.doc_id, this.update, this.callback);
  842. });
  843. // This happens in a setTimeout to allow the client a chance to receive the error first.
  844. // I'm not sure how to unit test, but it is acceptance tested.
  845. // it "should disconnect the client", ->
  846. // @client.disconnect.called.should.equal true
  847. it("should log a warning", function() {
  848. return this.logger.warn.called.should.equal(true);
  849. });
  850. return it("should call the callback with the error", function() {
  851. return this.callback.calledWith(this.error).should.equal(true);
  852. });
  853. });
  854. return describe("update_too_large", function() {
  855. beforeEach(function(done) {
  856. this.client.disconnect = sinon.stub();
  857. this.client.emit = sinon.stub();
  858. this.client.ol_context.user_id = this.user_id;
  859. this.client.ol_context.project_id = this.project_id;
  860. const error = new Error("update is too large");
  861. error.updateSize = 7372835;
  862. this.DocumentUpdaterManager.queueChange = sinon.stub().callsArgWith(3, error);
  863. this.WebsocketController.applyOtUpdate(this.client, this.doc_id, this.update, this.callback);
  864. return setTimeout(() => done()
  865. , 1);
  866. });
  867. it("should call the callback with no error", function() {
  868. this.callback.called.should.equal(true);
  869. return this.callback.args[0].should.deep.equal([]);
  870. });
  871. it("should log a warning with the size and context", function() {
  872. this.logger.warn.called.should.equal(true);
  873. return this.logger.warn.args[0].should.deep.equal([{
  874. user_id: this.user_id, project_id: this.project_id, doc_id: this.doc_id, updateSize: 7372835
  875. }, 'update is too large']);
  876. });
  877. describe("after 100ms", function() {
  878. beforeEach(function(done) { return setTimeout(done, 100); });
  879. it("should send an otUpdateError the client", function() {
  880. return this.client.emit.calledWith('otUpdateError').should.equal(true);
  881. });
  882. return it("should disconnect the client", function() {
  883. return this.client.disconnect.called.should.equal(true);
  884. });
  885. });
  886. return describe("when the client disconnects during the next 100ms", function() {
  887. beforeEach(function(done) {
  888. this.client.disconnected = true;
  889. return setTimeout(done, 100);
  890. });
  891. it("should not send an otUpdateError the client", function() {
  892. return this.client.emit.calledWith('otUpdateError').should.equal(false);
  893. });
  894. it("should not disconnect the client", function() {
  895. return this.client.disconnect.called.should.equal(false);
  896. });
  897. return it("should increment the editor.doc-update.disconnected metric with a status", function() {
  898. return expect(this.metrics.inc.calledWith('editor.doc-update.disconnected', 1, {status:'at-otUpdateError'})).to.equal(true);
  899. });
  900. });
  901. });
  902. });
  903. return describe("_assertClientCanApplyUpdate", function() {
  904. beforeEach(function() {
  905. this.edit_update = { op: [{i: "foo", p: 42}, {c: "bar", p: 132}] }; // comments may still be in an edit op
  906. this.comment_update = { op: [{c: "bar", p: 132}] };
  907. this.AuthorizationManager.assertClientCanEditProjectAndDoc = sinon.stub();
  908. return this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub();
  909. });
  910. describe("with a read-write client", function() { return it("should return successfully", function(done) {
  911. this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(null);
  912. return this.WebsocketController._assertClientCanApplyUpdate(this.client, this.doc_id, this.edit_update, (error) => {
  913. expect(error).to.be.null;
  914. return done();
  915. });
  916. }); });
  917. describe("with a read-only client and an edit op", function() { return it("should return an error", function(done) {
  918. this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(new Error("not authorized"));
  919. this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null);
  920. return this.WebsocketController._assertClientCanApplyUpdate(this.client, this.doc_id, this.edit_update, (error) => {
  921. expect(error.message).to.equal("not authorized");
  922. return done();
  923. });
  924. }); });
  925. describe("with a read-only client and a comment op", function() { return it("should return successfully", function(done) {
  926. this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(new Error("not authorized"));
  927. this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null);
  928. return this.WebsocketController._assertClientCanApplyUpdate(this.client, this.doc_id, this.comment_update, (error) => {
  929. expect(error).to.be.null;
  930. return done();
  931. });
  932. }); });
  933. return describe("with a totally unauthorized client", function() { return it("should return an error", function(done) {
  934. this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(new Error("not authorized"));
  935. this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(new Error("not authorized"));
  936. return this.WebsocketController._assertClientCanApplyUpdate(this.client, this.doc_id, this.comment_update, (error) => {
  937. expect(error.message).to.equal("not authorized");
  938. return done();
  939. });
  940. }); });
  941. });
  942. });