MatrixTests.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. /*
  2. This test suite is a multi level matrix which allows us to test many cases
  3. with all kinds of setups.
  4. Users/Actors are defined in USERS and are a low level entity that does connect
  5. to a real-time pod. A typical UserItem is:
  6. someDescriptiveNameForTheTestSuite: {
  7. setup(cb) {
  8. // <setup session here>
  9. const options = { client: RealTimeClient.connect(), foo: 'bar' }
  10. cb(null, options)
  11. }
  12. }
  13. Sessions are a set of actions that a User performs in the life-cycle of a
  14. real-time session, before they try something weird. A typical SessionItem is:
  15. someOtherDescriptiveNameForTheTestSuite: {
  16. getActions(cb) {
  17. cb(null, [
  18. { rpc: 'RPC_ENDPOINT', args: [...] }
  19. ])
  20. }
  21. }
  22. Finally there are InvalidRequests which are the weird actions I hinted on in
  23. the Sessions section. The defined actions may be marked as 'failed' to denote
  24. that real-time rejects them with an (for this test) expected error.
  25. A typical InvalidRequestItem is:
  26. joinOwnProject: {
  27. getActions(cb) {
  28. cb(null, [
  29. { rpc: 'RPC_ENDPOINT', args: [...], failed: true }
  30. ])
  31. }
  32. }
  33. There is additional meta-data that UserItems and SessionItems may use to skip
  34. certain areas of the matrix. Theses are:
  35. - Has the User an own project that they join as part of the Session?
  36. UserItem: { hasOwnProject: true, setup(cb) { cb(null, { project_id, ... }) }}
  37. SessionItem: { needsOwnProject: true }
  38. */
  39. import { expect } from 'chai'
  40. import async from 'async'
  41. import RealTimeClient from './helpers/RealTimeClient.js'
  42. import FixturesManager from './helpers/FixturesManager.js'
  43. import MockWebServer from './helpers/MockWebServer.js'
  44. import settings from '@overleaf/settings'
  45. import redis from '@overleaf/redis-wrapper'
  46. const Keys = settings.redis.documentupdater.key_schema
  47. const rclient = redis.createClient(settings.redis.pubsub)
  48. function getPendingUpdates(docId, cb) {
  49. rclient.lrange(Keys.pendingUpdates({ doc_id: docId }), 0, 10, cb)
  50. }
  51. function cleanupPreviousUpdates(docId, cb) {
  52. rclient.del(Keys.pendingUpdates({ doc_id: docId }), cb)
  53. }
  54. describe('MatrixTests', function () {
  55. let privateProjectId,
  56. privateDocId,
  57. readWriteProjectId,
  58. readWriteDocId,
  59. readWriteAnonymousAccessToken
  60. let privateClient
  61. before(function setupPrivateProject(done) {
  62. FixturesManager.setUpEditorSession(
  63. { privilegeLevel: 'owner', publicAccessLevel: 'readAndWrite' },
  64. (err, { project_id: projectId, doc_id: docId }) => {
  65. if (err) return done(err)
  66. privateProjectId = projectId
  67. privateDocId = docId
  68. privateClient = RealTimeClient.connect(projectId, err => {
  69. if (err) return done(err)
  70. privateClient.emit('joinDoc', privateDocId, done)
  71. })
  72. }
  73. )
  74. })
  75. before(function setupReadWriteProject(done) {
  76. FixturesManager.setUpEditorSession(
  77. {
  78. publicAccess: 'readAndWrite',
  79. },
  80. (err, { project_id: projectId, doc_id: docId, anonymousAccessToken }) => {
  81. readWriteProjectId = projectId
  82. readWriteDocId = docId
  83. readWriteAnonymousAccessToken = anonymousAccessToken
  84. done(err)
  85. }
  86. )
  87. })
  88. const USER_SETUP = {
  89. anonymous: {
  90. setup(cb) {
  91. RealTimeClient.setAnonSession(
  92. readWriteProjectId,
  93. readWriteAnonymousAccessToken,
  94. err => {
  95. if (err) return cb(err)
  96. cb(null, {})
  97. }
  98. )
  99. },
  100. },
  101. registered: {
  102. setup(cb) {
  103. const userId = FixturesManager.getRandomId()
  104. const user = { _id: userId, first_name: 'Joe', last_name: 'Bloggs' }
  105. RealTimeClient.setSession({ user }, err => {
  106. if (err) return cb(err)
  107. MockWebServer.inviteUserToProject(
  108. readWriteProjectId,
  109. user,
  110. 'readAndWrite'
  111. )
  112. cb(null, {
  113. user_id: userId,
  114. })
  115. })
  116. },
  117. },
  118. registeredWithOwnedProject: {
  119. setup(cb) {
  120. FixturesManager.setUpEditorSession(
  121. { privilegeLevel: 'owner' },
  122. (err, { project_id: projectId, user_id: userId, doc_id: docId }) => {
  123. if (err) return cb(err)
  124. MockWebServer.inviteUserToProject(
  125. readWriteProjectId,
  126. { _id: userId },
  127. 'readAndWrite'
  128. )
  129. cb(null, {
  130. user_id: userId,
  131. project_id: projectId,
  132. doc_id: docId,
  133. })
  134. }
  135. )
  136. },
  137. hasOwnProject: true,
  138. },
  139. }
  140. Object.entries(USER_SETUP).forEach(level0 => {
  141. const [userDescription, userItem] = level0
  142. let options, client
  143. const SESSION_SETUP = {
  144. joinReadWriteProject: {
  145. getActions(cb) {
  146. cb(null, [{ connect: readWriteProjectId }])
  147. },
  148. needsOwnProject: false,
  149. },
  150. joinReadWriteProjectAndDoc: {
  151. getActions(cb) {
  152. cb(null, [
  153. { connect: readWriteProjectId },
  154. { rpc: 'joinDoc', args: [readWriteDocId] },
  155. ])
  156. },
  157. needsOwnProject: false,
  158. },
  159. joinOwnProject: {
  160. getActions(cb) {
  161. cb(null, [{ connect: options.project_id }])
  162. },
  163. needsOwnProject: true,
  164. },
  165. joinOwnProjectAndDoc: {
  166. getActions(cb) {
  167. cb(null, [
  168. { connect: options.project_id },
  169. { rpc: 'joinDoc', args: [options.doc_id] },
  170. ])
  171. },
  172. needsOwnProject: true,
  173. },
  174. }
  175. function performActions(getActions, done) {
  176. getActions((err, actions) => {
  177. if (err) return done(err)
  178. async.eachSeries(
  179. actions,
  180. (action, next) => {
  181. const cb = (...returnedArgs) => {
  182. const error = returnedArgs.shift()
  183. if (action.fails) {
  184. expect(error).to.exist
  185. expect(returnedArgs).to.have.length(0)
  186. return next()
  187. }
  188. next(error)
  189. }
  190. if (action.connect) {
  191. client = RealTimeClient.connect(action.connect, cb)
  192. } else if (action.rpc) {
  193. if (client?.socket?.connected) {
  194. client.emit(action.rpc, ...action.args, cb)
  195. } else {
  196. cb(new Error('not connected!'))
  197. }
  198. } else {
  199. next(new Error('unexpected action'))
  200. }
  201. },
  202. done
  203. )
  204. })
  205. }
  206. describe(userDescription, function () {
  207. beforeEach(function userSetup(done) {
  208. userItem.setup((err, _options) => {
  209. if (err) return done(err)
  210. options = _options
  211. done()
  212. })
  213. })
  214. Object.entries(SESSION_SETUP).forEach(level1 => {
  215. const [sessionSetupDescription, sessionSetupItem] = level1
  216. const INVALID_REQUESTS = {
  217. noop: {
  218. getActions(cb) {
  219. cb(null, [])
  220. },
  221. },
  222. joinProjectWithBadAccessToken: {
  223. getActions(cb) {
  224. RealTimeClient.setAnonSession(
  225. privateProjectId,
  226. 'invalid-access-token',
  227. err => {
  228. if (err) return cb(err)
  229. cb(null, [
  230. {
  231. connect: privateProjectId,
  232. fails: 1,
  233. },
  234. ])
  235. }
  236. )
  237. },
  238. },
  239. joinProjectWithDocId: {
  240. getActions(cb) {
  241. cb(null, [
  242. {
  243. connect: privateDocId,
  244. fails: 1,
  245. },
  246. ])
  247. },
  248. },
  249. joinDocWithDocId: {
  250. getActions(cb) {
  251. cb(null, [{ rpc: 'joinDoc', args: [privateDocId], fails: 1 }])
  252. },
  253. },
  254. joinProjectWithProjectId: {
  255. getActions(cb) {
  256. cb(null, [
  257. {
  258. connect: privateProjectId,
  259. fails: 1,
  260. },
  261. ])
  262. },
  263. },
  264. joinDocWithProjectId: {
  265. getActions(cb) {
  266. cb(null, [{ rpc: 'joinDoc', args: [privateProjectId], fails: 1 }])
  267. },
  268. },
  269. joinProjectWithProjectIdThenJoinDocWithDocId: {
  270. getActions(cb) {
  271. cb(null, [
  272. {
  273. connect: privateProjectId,
  274. fails: 1,
  275. },
  276. { rpc: 'joinDoc', args: [privateDocId], fails: 1 },
  277. ])
  278. },
  279. },
  280. }
  281. // skip some areas of the matrix
  282. // - some Users do not have an own project
  283. const skip = sessionSetupItem.needsOwnProject && !userItem.hasOwnProject
  284. describe(sessionSetupDescription, function () {
  285. beforeEach(function performSessionActions(done) {
  286. if (skip) return this.skip()
  287. performActions(sessionSetupItem.getActions, done)
  288. })
  289. Object.entries(INVALID_REQUESTS).forEach(level2 => {
  290. const [InvalidRequestDescription, InvalidRequestItem] = level2
  291. describe(InvalidRequestDescription, function () {
  292. beforeEach(function performInvalidRequests(done) {
  293. performActions(InvalidRequestItem.getActions, done)
  294. })
  295. describe('rooms', function () {
  296. it('should not add the user into the privateProject room', function (done) {
  297. RealTimeClient.getConnectedClient(
  298. client.socket.sessionid,
  299. (error, client) => {
  300. if (error?.message === 'not found') return done() // disconnected
  301. if (error) return done(error)
  302. expect(client.rooms).to.not.include(privateProjectId)
  303. done()
  304. }
  305. )
  306. })
  307. it('should not add the user into the privateDoc room', function (done) {
  308. RealTimeClient.getConnectedClient(
  309. client.socket.sessionid,
  310. (error, client) => {
  311. if (error?.message === 'not found') return done() // disconnected
  312. if (error) return done(error)
  313. expect(client.rooms).to.not.include(privateDocId)
  314. done()
  315. }
  316. )
  317. })
  318. })
  319. describe('receive updates', function () {
  320. const receivedMessages = []
  321. beforeEach(function publishAnUpdateInRedis(done) {
  322. const update = {
  323. doc_id: privateDocId,
  324. op: {
  325. meta: { source: privateClient.publicId },
  326. v: 42,
  327. doc: privateDocId,
  328. op: [{ i: 'foo', p: 50 }],
  329. },
  330. }
  331. client.on('otUpdateApplied', update => {
  332. receivedMessages.push(update)
  333. })
  334. privateClient.once('otUpdateApplied', () => {
  335. setTimeout(done, 10)
  336. })
  337. rclient.publish('applied-ops', JSON.stringify(update))
  338. })
  339. it('should send nothing to client', function () {
  340. expect(receivedMessages).to.have.length(0)
  341. })
  342. })
  343. describe('receive messages from web', function () {
  344. const receivedMessages = []
  345. beforeEach(function publishAMessageInRedis(done) {
  346. const event = {
  347. room_id: privateProjectId,
  348. message: 'removeEntity',
  349. payload: ['foo', 'convertDocToFile'],
  350. _id: 'web:123',
  351. }
  352. client.on('removeEntity', (...args) => {
  353. receivedMessages.push(args)
  354. })
  355. privateClient.once('removeEntity', () => {
  356. setTimeout(done, 10)
  357. })
  358. rclient.publish('editor-events', JSON.stringify(event))
  359. })
  360. it('should send nothing to client', function () {
  361. expect(receivedMessages).to.have.length(0)
  362. })
  363. })
  364. describe('send updates', function () {
  365. let receivedArgs, submittedUpdates, update
  366. beforeEach(function cleanup(done) {
  367. cleanupPreviousUpdates(privateDocId, done)
  368. })
  369. beforeEach(function setupUpdateFields() {
  370. update = {
  371. doc_id: privateDocId,
  372. op: {
  373. v: 43,
  374. lastV: 42,
  375. doc: privateDocId,
  376. op: [{ i: 'foo', p: 50 }],
  377. },
  378. }
  379. })
  380. beforeEach(function sendAsUser(done) {
  381. if (!client?.socket?.connected) {
  382. // disconnected clients cannot emit messages
  383. return this.skip()
  384. }
  385. const userUpdate = Object.assign({}, update, {
  386. hash: 'user',
  387. })
  388. client.emit(
  389. 'applyOtUpdate',
  390. privateDocId,
  391. userUpdate,
  392. (...args) => {
  393. receivedArgs = args
  394. done()
  395. }
  396. )
  397. })
  398. beforeEach(function sendAsPrivateUserForReferenceOp(done) {
  399. const privateUpdate = Object.assign({}, update, {
  400. hash: 'private',
  401. })
  402. privateClient.emit(
  403. 'applyOtUpdate',
  404. privateDocId,
  405. privateUpdate,
  406. done
  407. )
  408. })
  409. beforeEach(function fetchPendingOps(done) {
  410. getPendingUpdates(privateDocId, (err, updates) => {
  411. submittedUpdates = updates
  412. done(err)
  413. })
  414. })
  415. it('should error out trying to send', function () {
  416. expect(receivedArgs).to.have.length(1)
  417. expect(receivedArgs[0]).to.have.property('message')
  418. // we are using an old version of chai: 1.9.2
  419. // TypeError: expect(...).to.be.oneOf is not a function
  420. expect(
  421. [
  422. 'no project_id found on client',
  423. 'not authorized',
  424. ].includes(receivedArgs[0].message)
  425. ).to.equal(true)
  426. })
  427. it('should submit the private users message only', function () {
  428. expect(submittedUpdates).to.have.length(1)
  429. const update = JSON.parse(submittedUpdates[0])
  430. expect(update.hash).to.equal('private')
  431. })
  432. })
  433. })
  434. })
  435. })
  436. })
  437. })
  438. })
  439. })