MatrixTests.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  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. const { expect } = require('chai')
  40. const async = require('async')
  41. const RealTimeClient = require('./helpers/RealTimeClient')
  42. const FixturesManager = require('./helpers/FixturesManager')
  43. const MockWebServer = require('./helpers/MockWebServer')
  44. const settings = require('@overleaf/settings')
  45. const Keys = settings.redis.documentupdater.key_schema
  46. const redis = require('@overleaf/redis-wrapper')
  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()
  69. privateClient.on('connectionAccepted', () => {
  70. privateClient.emit(
  71. 'joinProject',
  72. { project_id: privateProjectId },
  73. err => {
  74. if (err) return done(err)
  75. privateClient.emit('joinDoc', privateDocId, done)
  76. }
  77. )
  78. })
  79. }
  80. )
  81. })
  82. before(function setupReadWriteProject(done) {
  83. FixturesManager.setUpEditorSession(
  84. {
  85. publicAccess: 'readAndWrite',
  86. },
  87. (err, { project_id: projectId, doc_id: docId, anonymousAccessToken }) => {
  88. readWriteProjectId = projectId
  89. readWriteDocId = docId
  90. readWriteAnonymousAccessToken = anonymousAccessToken
  91. done(err)
  92. }
  93. )
  94. })
  95. const USER_SETUP = {
  96. anonymous: {
  97. getAnonymousAccessToken() {
  98. return readWriteAnonymousAccessToken
  99. },
  100. setup(cb) {
  101. RealTimeClient.setSession(
  102. {
  103. anonTokenAccess: {
  104. [readWriteProjectId]: readWriteAnonymousAccessToken,
  105. },
  106. },
  107. err => {
  108. if (err) return cb(err)
  109. cb(null, {
  110. client: RealTimeClient.connect(),
  111. })
  112. }
  113. )
  114. },
  115. },
  116. registered: {
  117. getAnonymousAccessToken() {},
  118. setup(cb) {
  119. const userId = FixturesManager.getRandomId()
  120. const user = { _id: userId, first_name: 'Joe', last_name: 'Bloggs' }
  121. RealTimeClient.setSession({ user }, err => {
  122. if (err) return cb(err)
  123. MockWebServer.inviteUserToProject(
  124. readWriteProjectId,
  125. user,
  126. 'readAndWrite'
  127. )
  128. cb(null, {
  129. user_id: userId,
  130. client: RealTimeClient.connect(),
  131. })
  132. })
  133. },
  134. },
  135. registeredWithOwnedProject: {
  136. getAnonymousAccessToken() {},
  137. setup(cb) {
  138. FixturesManager.setUpEditorSession(
  139. { privilegeLevel: 'owner' },
  140. (err, { project_id: projectId, user_id: userId, doc_id: docId }) => {
  141. if (err) return cb(err)
  142. MockWebServer.inviteUserToProject(
  143. readWriteProjectId,
  144. { _id: userId },
  145. 'readAndWrite'
  146. )
  147. cb(null, {
  148. user_id: userId,
  149. project_id: projectId,
  150. doc_id: docId,
  151. client: RealTimeClient.connect(),
  152. })
  153. }
  154. )
  155. },
  156. hasOwnProject: true,
  157. },
  158. }
  159. Object.entries(USER_SETUP).forEach(level0 => {
  160. const [userDescription, userItem] = level0
  161. let options, client
  162. const SESSION_SETUP = {
  163. noop: {
  164. getActions(cb) {
  165. cb(null, [])
  166. },
  167. needsOwnProject: false,
  168. },
  169. joinReadWriteProject: {
  170. getActions(cb) {
  171. const anonymousAccessToken = userItem.getAnonymousAccessToken()
  172. cb(null, [
  173. {
  174. rpc: 'joinProject',
  175. args: [{ project_id: readWriteProjectId, anonymousAccessToken }],
  176. },
  177. ])
  178. },
  179. needsOwnProject: false,
  180. },
  181. joinReadWriteProjectAndDoc: {
  182. getActions(cb) {
  183. const anonymousAccessToken = userItem.getAnonymousAccessToken()
  184. cb(null, [
  185. {
  186. rpc: 'joinProject',
  187. args: [{ project_id: readWriteProjectId, anonymousAccessToken }],
  188. },
  189. { rpc: 'joinDoc', args: [readWriteDocId] },
  190. ])
  191. },
  192. needsOwnProject: false,
  193. },
  194. joinOwnProject: {
  195. getActions(cb) {
  196. const anonymousAccessToken = userItem.getAnonymousAccessToken()
  197. cb(null, [
  198. {
  199. rpc: 'joinProject',
  200. args: [{ project_id: options.project_id, anonymousAccessToken }],
  201. },
  202. ])
  203. },
  204. needsOwnProject: true,
  205. },
  206. joinOwnProjectAndDoc: {
  207. getActions(cb) {
  208. const anonymousAccessToken = userItem.getAnonymousAccessToken()
  209. cb(null, [
  210. {
  211. rpc: 'joinProject',
  212. args: [{ project_id: options.project_id, anonymousAccessToken }],
  213. },
  214. { rpc: 'joinDoc', args: [options.doc_id] },
  215. ])
  216. },
  217. needsOwnProject: true,
  218. },
  219. }
  220. function performActions(getActions, done) {
  221. getActions((err, actions) => {
  222. if (err) return done(err)
  223. async.eachSeries(
  224. actions,
  225. (action, cb) => {
  226. if (action.rpc) {
  227. client.emit(action.rpc, ...action.args, (...returnedArgs) => {
  228. const error = returnedArgs.shift()
  229. if (action.fails) {
  230. expect(error).to.exist
  231. expect(returnedArgs).to.have.length(0)
  232. return cb()
  233. }
  234. cb(error)
  235. })
  236. } else {
  237. cb(new Error('unexpected action'))
  238. }
  239. },
  240. done
  241. )
  242. })
  243. }
  244. describe(userDescription, function () {
  245. beforeEach(function userSetup(done) {
  246. userItem.setup((err, _options) => {
  247. if (err) return done(err)
  248. options = _options
  249. client = options.client
  250. client.on('connectionAccepted', done)
  251. })
  252. })
  253. Object.entries(SESSION_SETUP).forEach(level1 => {
  254. const [sessionSetupDescription, sessionSetupItem] = level1
  255. const INVALID_REQUESTS = {
  256. noop: {
  257. getActions(cb) {
  258. cb(null, [])
  259. },
  260. },
  261. joinProjectWithBadAccessToken: {
  262. getActions(cb) {
  263. cb(null, [
  264. {
  265. rpc: 'joinProject',
  266. args: [
  267. {
  268. project_id: privateProjectId,
  269. anonymousAccessToken: 'invalid-access-token',
  270. },
  271. ],
  272. fails: 1,
  273. },
  274. ])
  275. },
  276. },
  277. joinProjectWithDocId: {
  278. getActions(cb) {
  279. cb(null, [
  280. {
  281. rpc: 'joinProject',
  282. args: [{ project_id: privateDocId }],
  283. fails: 1,
  284. },
  285. ])
  286. },
  287. },
  288. joinDocWithDocId: {
  289. getActions(cb) {
  290. cb(null, [{ rpc: 'joinDoc', args: [privateDocId], fails: 1 }])
  291. },
  292. },
  293. joinProjectWithProjectId: {
  294. getActions(cb) {
  295. cb(null, [
  296. {
  297. rpc: 'joinProject',
  298. args: [{ project_id: privateProjectId }],
  299. fails: 1,
  300. },
  301. ])
  302. },
  303. },
  304. joinDocWithProjectId: {
  305. getActions(cb) {
  306. cb(null, [{ rpc: 'joinDoc', args: [privateProjectId], fails: 1 }])
  307. },
  308. },
  309. joinProjectWithProjectIdThenJoinDocWithDocId: {
  310. getActions(cb) {
  311. cb(null, [
  312. {
  313. rpc: 'joinProject',
  314. args: [{ project_id: privateProjectId }],
  315. fails: 1,
  316. },
  317. { rpc: 'joinDoc', args: [privateDocId], fails: 1 },
  318. ])
  319. },
  320. },
  321. }
  322. // skip some areas of the matrix
  323. // - some Users do not have an own project
  324. const skip = sessionSetupItem.needsOwnProject && !userItem.hasOwnProject
  325. describe(sessionSetupDescription, function () {
  326. beforeEach(function performSessionActions(done) {
  327. if (skip) return this.skip()
  328. performActions(sessionSetupItem.getActions, done)
  329. })
  330. Object.entries(INVALID_REQUESTS).forEach(level2 => {
  331. const [InvalidRequestDescription, InvalidRequestItem] = level2
  332. describe(InvalidRequestDescription, function () {
  333. beforeEach(function performInvalidRequests(done) {
  334. performActions(InvalidRequestItem.getActions, done)
  335. })
  336. describe('rooms', function () {
  337. it('should not add the user into the privateProject room', function (done) {
  338. RealTimeClient.getConnectedClient(
  339. client.socket.sessionid,
  340. (error, client) => {
  341. if (error) return done(error)
  342. expect(client.rooms).to.not.include(privateProjectId)
  343. done()
  344. }
  345. )
  346. })
  347. it('should not add the user into the privateDoc room', function (done) {
  348. RealTimeClient.getConnectedClient(
  349. client.socket.sessionid,
  350. (error, client) => {
  351. if (error) return done(error)
  352. expect(client.rooms).to.not.include(privateDocId)
  353. done()
  354. }
  355. )
  356. })
  357. })
  358. describe('receive updates', function () {
  359. const receivedMessages = []
  360. beforeEach(function publishAnUpdateInRedis(done) {
  361. const update = {
  362. doc_id: privateDocId,
  363. op: {
  364. meta: { source: privateClient.publicId },
  365. v: 42,
  366. doc: privateDocId,
  367. op: [{ i: 'foo', p: 50 }],
  368. },
  369. }
  370. client.on('otUpdateApplied', update => {
  371. receivedMessages.push(update)
  372. })
  373. privateClient.once('otUpdateApplied', () => {
  374. setTimeout(done, 10)
  375. })
  376. rclient.publish('applied-ops', JSON.stringify(update))
  377. })
  378. it('should send nothing to client', function () {
  379. expect(receivedMessages).to.have.length(0)
  380. })
  381. })
  382. describe('receive messages from web', function () {
  383. const receivedMessages = []
  384. beforeEach(function publishAMessageInRedis(done) {
  385. const event = {
  386. room_id: privateProjectId,
  387. message: 'removeEntity',
  388. payload: ['foo', 'convertDocToFile'],
  389. _id: 'web:123',
  390. }
  391. client.on('removeEntity', (...args) => {
  392. receivedMessages.push(args)
  393. })
  394. privateClient.once('removeEntity', () => {
  395. setTimeout(done, 10)
  396. })
  397. rclient.publish('editor-events', JSON.stringify(event))
  398. })
  399. it('should send nothing to client', function () {
  400. expect(receivedMessages).to.have.length(0)
  401. })
  402. })
  403. describe('send updates', function () {
  404. let receivedArgs, submittedUpdates, update
  405. beforeEach(function cleanup(done) {
  406. cleanupPreviousUpdates(privateDocId, done)
  407. })
  408. beforeEach(function setupUpdateFields() {
  409. update = {
  410. doc_id: privateDocId,
  411. op: {
  412. v: 43,
  413. lastV: 42,
  414. doc: privateDocId,
  415. op: [{ i: 'foo', p: 50 }],
  416. },
  417. }
  418. })
  419. beforeEach(function sendAsUser(done) {
  420. const userUpdate = Object.assign({}, update, {
  421. hash: 'user',
  422. })
  423. client.emit(
  424. 'applyOtUpdate',
  425. privateDocId,
  426. userUpdate,
  427. (...args) => {
  428. receivedArgs = args
  429. done()
  430. }
  431. )
  432. })
  433. beforeEach(function sendAsPrivateUserForReferenceOp(done) {
  434. const privateUpdate = Object.assign({}, update, {
  435. hash: 'private',
  436. })
  437. privateClient.emit(
  438. 'applyOtUpdate',
  439. privateDocId,
  440. privateUpdate,
  441. done
  442. )
  443. })
  444. beforeEach(function fetchPendingOps(done) {
  445. getPendingUpdates(privateDocId, (err, updates) => {
  446. submittedUpdates = updates
  447. done(err)
  448. })
  449. })
  450. it('should error out trying to send', function () {
  451. expect(receivedArgs).to.have.length(1)
  452. expect(receivedArgs[0]).to.have.property('message')
  453. // we are using an old version of chai: 1.9.2
  454. // TypeError: expect(...).to.be.oneOf is not a function
  455. expect(
  456. [
  457. 'no project_id found on client',
  458. 'not authorized',
  459. ].includes(receivedArgs[0].message)
  460. ).to.equal(true)
  461. })
  462. it('should submit the private users message only', function () {
  463. expect(submittedUpdates).to.have.length(1)
  464. const update = JSON.parse(submittedUpdates[0])
  465. expect(update.hash).to.equal('private')
  466. })
  467. })
  468. })
  469. })
  470. })
  471. })
  472. })
  473. })
  474. })