WebsocketLoadBalancer.test.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. import { vi, expect, describe, beforeEach, it } from 'vitest'
  2. import sinon from 'sinon'
  3. import path from 'node:path'
  4. const modulePath = path.join(
  5. import.meta.dirname,
  6. '../../../app/js/WebsocketLoadBalancer'
  7. )
  8. describe('WebsocketLoadBalancer', function () {
  9. beforeEach(async function (ctx) {
  10. ctx.rclient = {}
  11. ctx.RoomEvents = { on: sinon.stub() }
  12. vi.doMock('@overleaf/settings', () => ({
  13. default: (ctx.Settings = { redis: {} }),
  14. }))
  15. vi.doMock('./RedisClientManager', () => ({
  16. default: {
  17. createClientList: () => [],
  18. },
  19. }))
  20. vi.doMock('../../../app/js/SafeJsonParse', () => ({
  21. default: (ctx.SafeJsonParse = {
  22. parse: (data, cb) => cb(null, JSON.parse(data)),
  23. }),
  24. }))
  25. vi.doMock('../../../app/js/EventLogger', () => ({
  26. default: { checkEventOrder: sinon.stub() },
  27. }))
  28. vi.doMock('../../../app/js/HealthCheckManager', () => ({
  29. default: { check: sinon.stub() },
  30. }))
  31. vi.doMock('../../../app/js/RoomManager', () => ({
  32. default: (ctx.RoomManager = {
  33. eventSource: sinon.stub().returns(ctx.RoomEvents),
  34. }),
  35. }))
  36. vi.doMock('../../../app/js/ChannelManager', () => ({
  37. default: (ctx.ChannelManager = { publish: sinon.stub() }),
  38. }))
  39. vi.doMock('../../../app/js/ConnectedUsersManager', () => ({
  40. default: (ctx.ConnectedUsersManager = {
  41. refreshClient: sinon.stub(),
  42. }),
  43. }))
  44. ctx.WebsocketLoadBalancer = (await import(modulePath)).default
  45. ctx.io = {}
  46. ctx.WebsocketLoadBalancer.rclientPubList = [{ publish: sinon.stub() }]
  47. ctx.WebsocketLoadBalancer.rclientSubList = [
  48. {
  49. subscribe: sinon.stub(),
  50. on: sinon.stub(),
  51. },
  52. ]
  53. ctx.room_id = 'room-id'
  54. ctx.message = 'otUpdateApplied'
  55. ctx.payload = ['argument one', 42]
  56. })
  57. describe('shouldDisconnectClient', function () {
  58. it('should return false for general messages', function (ctx) {
  59. const client = {
  60. ol_context: { user_id: 'abcd' },
  61. }
  62. const message = {
  63. message: 'someNiceMessage',
  64. payload: [{ data: 'whatever' }],
  65. }
  66. expect(
  67. ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
  68. ).to.equal(false)
  69. })
  70. describe('collaborator access level changed', function () {
  71. const messageName = 'project:collaboratorAccessLevel:changed'
  72. const client = {
  73. ol_context: { user_id: 'abcd' },
  74. }
  75. it('should return true if the user id matches', function (ctx) {
  76. const message = {
  77. message: messageName,
  78. payload: [
  79. {
  80. userId: 'abcd',
  81. },
  82. ],
  83. }
  84. expect(
  85. ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
  86. ).to.equal(true)
  87. })
  88. it('should return false if the user id does not match', function (ctx) {
  89. const message = {
  90. message: messageName,
  91. payload: [
  92. {
  93. userId: 'xyz',
  94. },
  95. ],
  96. }
  97. expect(
  98. ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
  99. ).to.equal(false)
  100. })
  101. })
  102. describe('user removed from project', function () {
  103. const messageName = 'userRemovedFromProject'
  104. const client = {
  105. ol_context: { user_id: 'abcd' },
  106. }
  107. it('should return false, when the user_id does not match', function (ctx) {
  108. const message = {
  109. message: messageName,
  110. payload: ['xyz'],
  111. }
  112. expect(
  113. ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
  114. ).to.equal(false)
  115. })
  116. it('should return true, if the user_id matches', function (ctx) {
  117. const message = {
  118. message: messageName,
  119. payload: [`${client.ol_context.user_id}`],
  120. }
  121. expect(
  122. ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
  123. ).to.equal(true)
  124. })
  125. })
  126. describe('link-sharing turned off', function () {
  127. const messageName = 'project:publicAccessLevel:changed'
  128. describe('when the new access level is set to "private"', function () {
  129. const message = {
  130. message: messageName,
  131. payload: [{ newAccessLevel: 'private' }],
  132. }
  133. describe('when the user is an invited member', function () {
  134. const client = {
  135. ol_context: {
  136. is_invited_member: true,
  137. },
  138. }
  139. it('should return false', function (ctx) {
  140. expect(
  141. ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
  142. ).to.equal(false)
  143. })
  144. })
  145. describe('when the user not an invited member', function () {
  146. const client = {
  147. ol_context: {
  148. is_invited_member: false,
  149. },
  150. }
  151. it('should return true', function (ctx) {
  152. expect(
  153. ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
  154. ).to.equal(true)
  155. })
  156. })
  157. })
  158. describe('when the new access level is "tokenBased"', function () {
  159. const message = {
  160. message: messageName,
  161. payload: [{ newAccessLevel: 'tokenBased' }],
  162. }
  163. describe('when the user is an invited member', function () {
  164. const client = {
  165. ol_context: {
  166. is_invited_member: true,
  167. },
  168. }
  169. it('should return false', function (ctx) {
  170. expect(
  171. ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
  172. ).to.equal(false)
  173. })
  174. })
  175. describe('when the user not an invited member', function () {
  176. const client = {
  177. ol_context: {
  178. is_invited_member: false,
  179. },
  180. }
  181. it('should return false', function (ctx) {
  182. expect(
  183. ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
  184. ).to.equal(false)
  185. })
  186. })
  187. })
  188. })
  189. })
  190. describe('emitToRoom', function () {
  191. beforeEach(function (ctx) {
  192. ctx.WebsocketLoadBalancer.emitToRoom(
  193. ctx.room_id,
  194. ctx.message,
  195. ...Array.from(ctx.payload)
  196. )
  197. })
  198. it('should publish the message to redis', function (ctx) {
  199. ctx.ChannelManager.publish
  200. .calledWith(
  201. ctx.WebsocketLoadBalancer.rclientPubList[0],
  202. 'editor-events',
  203. ctx.room_id,
  204. JSON.stringify({
  205. room_id: ctx.room_id,
  206. message: ctx.message,
  207. payload: ctx.payload,
  208. })
  209. )
  210. .should.equal(true)
  211. })
  212. })
  213. describe('emitToAll', function () {
  214. beforeEach(function (ctx) {
  215. ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub()
  216. ctx.WebsocketLoadBalancer.emitToAll(
  217. ctx.message,
  218. ...Array.from(ctx.payload)
  219. )
  220. })
  221. it("should emit to the room 'all'", function (ctx) {
  222. ctx.WebsocketLoadBalancer.emitToRoom
  223. .calledWith('all', ctx.message, ...Array.from(ctx.payload))
  224. .should.equal(true)
  225. })
  226. })
  227. describe('listenForEditorEvents', function () {
  228. beforeEach(function (ctx) {
  229. ctx.WebsocketLoadBalancer._processEditorEvent = sinon.stub()
  230. ctx.WebsocketLoadBalancer.listenForEditorEvents()
  231. })
  232. it('should subscribe to the editor-events channel', function (ctx) {
  233. ctx.WebsocketLoadBalancer.rclientSubList[0].subscribe
  234. .calledWith('editor-events')
  235. .should.equal(true)
  236. })
  237. it('should process the events with _processEditorEvent', function (ctx) {
  238. ctx.WebsocketLoadBalancer.rclientSubList[0].on
  239. .calledWith('message', sinon.match.func)
  240. .should.equal(true)
  241. })
  242. })
  243. describe('_processEditorEvent', function () {
  244. describe('with bad JSON', function () {
  245. beforeEach(function (ctx) {
  246. ctx.isRestrictedUser = false
  247. ctx.SafeJsonParse.parse = sinon
  248. .stub()
  249. .callsArgWith(1, new Error('oops'))
  250. ctx.WebsocketLoadBalancer._processEditorEvent(
  251. ctx.io,
  252. 'editor-events',
  253. 'blah'
  254. )
  255. })
  256. it('should log an error', function (ctx) {
  257. ctx.logger.error.called.should.equal(true)
  258. })
  259. })
  260. describe('with a designated room', function () {
  261. beforeEach(function (ctx) {
  262. ctx.io.sockets = {
  263. clients: sinon.stub().returns([
  264. {
  265. id: 'client-id-1',
  266. emit: (ctx.emit1 = sinon.stub()),
  267. ol_context: {},
  268. },
  269. {
  270. id: 'client-id-2',
  271. emit: (ctx.emit2 = sinon.stub()),
  272. ol_context: {},
  273. },
  274. {
  275. id: 'client-id-1',
  276. emit: (ctx.emit3 = sinon.stub()),
  277. ol_context: {},
  278. }, // duplicate client
  279. ]),
  280. }
  281. const data = JSON.stringify({
  282. room_id: ctx.room_id,
  283. message: ctx.message,
  284. payload: ctx.payload,
  285. })
  286. ctx.WebsocketLoadBalancer._processEditorEvent(
  287. ctx.io,
  288. 'editor-events',
  289. data
  290. )
  291. })
  292. it('should send the message to all (unique) clients in the room', function (ctx) {
  293. ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true)
  294. ctx.emit1
  295. .calledWith(ctx.message, ...Array.from(ctx.payload))
  296. .should.equal(true)
  297. ctx.emit2
  298. .calledWith(ctx.message, ...Array.from(ctx.payload))
  299. .should.equal(true)
  300. ctx.emit3.called.should.equal(false)
  301. })
  302. }) // duplicate client should be ignored
  303. describe('with a designated room, and restricted clients, not restricted message', function () {
  304. beforeEach(function (ctx) {
  305. ctx.io.sockets = {
  306. clients: sinon.stub().returns([
  307. {
  308. id: 'client-id-1',
  309. emit: (ctx.emit1 = sinon.stub()),
  310. ol_context: {},
  311. },
  312. {
  313. id: 'client-id-2',
  314. emit: (ctx.emit2 = sinon.stub()),
  315. ol_context: {},
  316. },
  317. {
  318. id: 'client-id-1',
  319. emit: (ctx.emit3 = sinon.stub()),
  320. ol_context: {},
  321. }, // duplicate client
  322. {
  323. id: 'client-id-4',
  324. emit: (ctx.emit4 = sinon.stub()),
  325. ol_context: { is_restricted_user: true },
  326. },
  327. ]),
  328. }
  329. const data = JSON.stringify({
  330. room_id: ctx.room_id,
  331. message: ctx.message,
  332. payload: ctx.payload,
  333. })
  334. ctx.WebsocketLoadBalancer._processEditorEvent(
  335. ctx.io,
  336. 'editor-events',
  337. data
  338. )
  339. })
  340. it('should send the message to all (unique) clients in the room', function (ctx) {
  341. ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true)
  342. ctx.emit1
  343. .calledWith(ctx.message, ...Array.from(ctx.payload))
  344. .should.equal(true)
  345. ctx.emit2
  346. .calledWith(ctx.message, ...Array.from(ctx.payload))
  347. .should.equal(true)
  348. ctx.emit3.called.should.equal(false) // duplicate client should be ignored
  349. ctx.emit4.called.should.equal(true)
  350. })
  351. }) // restricted client, but should be called
  352. describe('with a designated room, and restricted clients, restricted message', function () {
  353. beforeEach(function (ctx) {
  354. ctx.io.sockets = {
  355. clients: sinon.stub().returns([
  356. {
  357. id: 'client-id-1',
  358. emit: (ctx.emit1 = sinon.stub()),
  359. ol_context: {},
  360. },
  361. {
  362. id: 'client-id-2',
  363. emit: (ctx.emit2 = sinon.stub()),
  364. ol_context: {},
  365. },
  366. {
  367. id: 'client-id-1',
  368. emit: (ctx.emit3 = sinon.stub()),
  369. ol_context: {},
  370. }, // duplicate client
  371. {
  372. id: 'client-id-4',
  373. emit: (ctx.emit4 = sinon.stub()),
  374. ol_context: { is_restricted_user: true },
  375. },
  376. ]),
  377. }
  378. const data = JSON.stringify({
  379. room_id: ctx.room_id,
  380. message: (ctx.restrictedMessage = 'new-comment'),
  381. payload: ctx.payload,
  382. })
  383. ctx.WebsocketLoadBalancer._processEditorEvent(
  384. ctx.io,
  385. 'editor-events',
  386. data
  387. )
  388. })
  389. it('should send the message to all (unique) clients in the room, who are not restricted', function (ctx) {
  390. ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true)
  391. ctx.emit1
  392. .calledWith(ctx.restrictedMessage, ...Array.from(ctx.payload))
  393. .should.equal(true)
  394. ctx.emit2
  395. .calledWith(ctx.restrictedMessage, ...Array.from(ctx.payload))
  396. .should.equal(true)
  397. ctx.emit3.called.should.equal(false) // duplicate client should be ignored
  398. ctx.emit4.called.should.equal(false)
  399. })
  400. }) // restricted client, should not be called
  401. describe('when emitting to all', function () {
  402. beforeEach(function (ctx) {
  403. ctx.io.sockets = { emit: (ctx.emit = sinon.stub()) }
  404. const data = JSON.stringify({
  405. room_id: 'all',
  406. message: ctx.message,
  407. payload: ctx.payload,
  408. })
  409. ctx.WebsocketLoadBalancer._processEditorEvent(
  410. ctx.io,
  411. 'editor-events',
  412. data
  413. )
  414. })
  415. it('should send the message to all clients', function (ctx) {
  416. ctx.emit
  417. .calledWith(ctx.message, ...Array.from(ctx.payload))
  418. .should.equal(true)
  419. })
  420. })
  421. describe('when it should disconnect one of the clients', function () {
  422. const targetUserId = 'bbb'
  423. const message = 'userRemovedFromProject'
  424. const payload = [`${targetUserId}`]
  425. const clients = [
  426. {
  427. id: 'client-id-1',
  428. emit: sinon.stub(),
  429. ol_context: { user_id: 'aaa' },
  430. disconnect: sinon.stub(),
  431. },
  432. {
  433. id: 'client-id-2',
  434. emit: sinon.stub(),
  435. ol_context: { user_id: `${targetUserId}` },
  436. disconnect: sinon.stub(),
  437. },
  438. {
  439. id: 'client-id-3',
  440. emit: sinon.stub(),
  441. ol_context: { user_id: 'ccc' },
  442. disconnect: sinon.stub(),
  443. },
  444. ]
  445. beforeEach(function (ctx) {
  446. ctx.io.sockets = {
  447. clients: sinon.stub().returns(clients),
  448. }
  449. const data = JSON.stringify({
  450. room_id: ctx.room_id,
  451. message,
  452. payload,
  453. })
  454. ctx.WebsocketLoadBalancer._processEditorEvent(
  455. ctx.io,
  456. 'editor-events',
  457. data
  458. )
  459. })
  460. it('should disconnect the matching client, while sending message to other clients', function (ctx) {
  461. ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true)
  462. const [client1, client2, client3] = clients
  463. // disconnecting one client
  464. client1.disconnect.called.should.equal(false)
  465. client2.disconnect.called.should.equal(true)
  466. client3.disconnect.called.should.equal(false)
  467. // emitting to remaining clients
  468. client1.emit
  469. .calledWith(message, ...Array.from(payload))
  470. .should.equal(true)
  471. client2.emit.calledWith('project:access:revoked').should.equal(true) // disconnected client should get informative message
  472. client3.emit
  473. .calledWith(message, ...Array.from(payload))
  474. .should.equal(true)
  475. })
  476. })
  477. })
  478. })