DocManagerTests.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  1. const SandboxedModule = require('sandboxed-module')
  2. const sinon = require('sinon')
  3. const { expect } = require('chai')
  4. const modulePath = require('node:path').join(
  5. __dirname,
  6. '../../../app/js/DocManager'
  7. )
  8. const { ObjectId } = require('mongodb-legacy')
  9. const Errors = require('../../../app/js/Errors')
  10. describe('DocManager', function () {
  11. beforeEach(function () {
  12. this.doc_id = new ObjectId().toString()
  13. this.project_id = new ObjectId().toString()
  14. this.another_project_id = new ObjectId().toString()
  15. this.stubbedError = new Error('blew up')
  16. this.version = 42
  17. this.MongoManager = {
  18. findDoc: sinon.stub(),
  19. getProjectsDocs: sinon.stub(),
  20. patchDoc: sinon.stub().resolves(),
  21. upsertIntoDocCollection: sinon.stub().resolves(),
  22. }
  23. this.DocArchiveManager = {
  24. unarchiveDoc: sinon.stub(),
  25. unArchiveAllDocs: sinon.stub(),
  26. archiveDoc: sinon.stub().resolves(),
  27. }
  28. this.RangeManager = {
  29. jsonRangesToMongo(r) {
  30. return r
  31. },
  32. shouldUpdateRanges: sinon.stub().returns(false),
  33. }
  34. this.settings = { docstore: {} }
  35. this.DocManager = SandboxedModule.require(modulePath, {
  36. requires: {
  37. './MongoManager': this.MongoManager,
  38. './DocArchiveManager': this.DocArchiveManager,
  39. './RangeManager': this.RangeManager,
  40. '@overleaf/settings': this.settings,
  41. './Errors': Errors,
  42. },
  43. })
  44. })
  45. describe('getFullDoc', function () {
  46. beforeEach(function () {
  47. this.DocManager._getDoc = sinon.stub()
  48. this.doc = {
  49. _id: this.doc_id,
  50. lines: ['2134'],
  51. }
  52. })
  53. it('should call get doc with a quick filter', async function () {
  54. this.DocManager._getDoc.resolves(this.doc)
  55. const doc = await this.DocManager.getFullDoc(this.project_id, this.doc_id)
  56. doc.should.equal(this.doc)
  57. this.DocManager._getDoc
  58. .calledWith(this.project_id, this.doc_id, {
  59. lines: true,
  60. rev: true,
  61. deleted: true,
  62. version: true,
  63. ranges: true,
  64. inS3: true,
  65. })
  66. .should.equal(true)
  67. })
  68. it('should return error when get doc errors', async function () {
  69. this.DocManager._getDoc.rejects(this.stubbedError)
  70. await expect(
  71. this.DocManager.getFullDoc(this.project_id, this.doc_id)
  72. ).to.be.rejectedWith(this.stubbedError)
  73. })
  74. })
  75. describe('getRawDoc', function () {
  76. beforeEach(function () {
  77. this.DocManager._getDoc = sinon.stub()
  78. this.doc = { lines: ['2134'] }
  79. })
  80. it('should call get doc with a quick filter', async function () {
  81. this.DocManager._getDoc.resolves(this.doc)
  82. const content = await this.DocManager.getDocLines(
  83. this.project_id,
  84. this.doc_id
  85. )
  86. content.should.equal(this.doc.lines.join('\n'))
  87. this.DocManager._getDoc
  88. .calledWith(this.project_id, this.doc_id, {
  89. lines: true,
  90. inS3: true,
  91. })
  92. .should.equal(true)
  93. })
  94. it('should return error when get doc errors', async function () {
  95. this.DocManager._getDoc.rejects(this.stubbedError)
  96. await expect(
  97. this.DocManager.getDocLines(this.project_id, this.doc_id)
  98. ).to.be.rejectedWith(this.stubbedError)
  99. })
  100. it('should return error when get doc does not exist', async function () {
  101. this.DocManager._getDoc.resolves(null)
  102. await expect(
  103. this.DocManager.getDocLines(this.project_id, this.doc_id)
  104. ).to.be.rejectedWith(Errors.NotFoundError)
  105. })
  106. it('should return error when get doc has no lines', async function () {
  107. this.DocManager._getDoc.resolves({})
  108. await expect(
  109. this.DocManager.getDocLines(this.project_id, this.doc_id)
  110. ).to.be.rejectedWith(Errors.DocWithoutLinesError)
  111. })
  112. })
  113. describe('getDoc', function () {
  114. beforeEach(function () {
  115. this.project = { name: 'mock-project' }
  116. this.doc = {
  117. _id: this.doc_id,
  118. project_id: this.project_id,
  119. lines: ['mock-lines'],
  120. version: this.version,
  121. }
  122. })
  123. describe('when using a filter', function () {
  124. beforeEach(function () {
  125. this.MongoManager.findDoc.resolves(this.doc)
  126. })
  127. it('should error if inS3 is not set to true', async function () {
  128. await expect(
  129. this.DocManager._getDoc(this.project_id, this.doc_id, {
  130. inS3: false,
  131. })
  132. ).to.be.rejected
  133. })
  134. it('should always get inS3 even when no filter is passed', async function () {
  135. await expect(this.DocManager._getDoc(this.project_id, this.doc_id)).to
  136. .be.rejected
  137. this.MongoManager.findDoc.called.should.equal(false)
  138. })
  139. it('should not error if inS3 is set to true', async function () {
  140. await this.DocManager._getDoc(this.project_id, this.doc_id, {
  141. inS3: true,
  142. })
  143. })
  144. })
  145. describe('when the doc is in the doc collection', function () {
  146. beforeEach(async function () {
  147. this.MongoManager.findDoc.resolves(this.doc)
  148. this.result = await this.DocManager._getDoc(
  149. this.project_id,
  150. this.doc_id,
  151. { version: true, inS3: true }
  152. )
  153. })
  154. it('should get the doc from the doc collection', function () {
  155. this.MongoManager.findDoc
  156. .calledWith(this.project_id, this.doc_id)
  157. .should.equal(true)
  158. })
  159. it('should return the doc with the version', function () {
  160. this.result.lines.should.equal(this.doc.lines)
  161. this.result.version.should.equal(this.version)
  162. })
  163. })
  164. describe('when MongoManager.findDoc errors', function () {
  165. it('should return the error', async function () {
  166. this.MongoManager.findDoc.rejects(this.stubbedError)
  167. await expect(
  168. this.DocManager._getDoc(this.project_id, this.doc_id, {
  169. version: true,
  170. inS3: true,
  171. })
  172. ).to.be.rejectedWith(this.stubbedError)
  173. })
  174. })
  175. describe('when the doc is archived', function () {
  176. beforeEach(async function () {
  177. this.doc = {
  178. _id: this.doc_id,
  179. project_id: this.project_id,
  180. version: 2,
  181. inS3: true,
  182. }
  183. this.unarchivedDoc = {
  184. _id: this.doc_id,
  185. project_id: this.project_id,
  186. lines: ['mock-lines'],
  187. version: 2,
  188. inS3: false,
  189. }
  190. this.MongoManager.findDoc.resolves(this.doc)
  191. this.DocArchiveManager.unarchiveDoc.callsFake(
  192. async (projectId, docId) => {
  193. this.MongoManager.findDoc.resolves({
  194. ...this.unarchivedDoc,
  195. })
  196. }
  197. )
  198. this.result = await this.DocManager._getDoc(
  199. this.project_id,
  200. this.doc_id,
  201. {
  202. version: true,
  203. inS3: true,
  204. }
  205. )
  206. })
  207. it('should call the DocArchive to unarchive the doc', function () {
  208. this.DocArchiveManager.unarchiveDoc
  209. .calledWith(this.project_id, this.doc_id)
  210. .should.equal(true)
  211. })
  212. it('should look up the doc twice', function () {
  213. this.MongoManager.findDoc.calledTwice.should.equal(true)
  214. })
  215. it('should return the doc', function () {
  216. expect(this.result).to.deep.equal({
  217. ...this.unarchivedDoc,
  218. })
  219. })
  220. })
  221. describe('when the doc does not exist in the docs collection', function () {
  222. it('should return a NotFoundError', async function () {
  223. this.MongoManager.findDoc.resolves(null)
  224. await expect(
  225. this.DocManager._getDoc(this.project_id, this.doc_id, {
  226. version: true,
  227. inS3: true,
  228. })
  229. ).to.be.rejectedWith(
  230. `No such doc: ${this.doc_id} in project ${this.project_id}`
  231. )
  232. })
  233. })
  234. })
  235. describe('getAllNonDeletedDocs', function () {
  236. describe('when the project exists', function () {
  237. beforeEach(async function () {
  238. this.docs = [
  239. {
  240. _id: this.doc_id,
  241. project_id: this.project_id,
  242. lines: ['mock-lines'],
  243. },
  244. ]
  245. this.MongoManager.getProjectsDocs.resolves(this.docs)
  246. this.DocArchiveManager.unArchiveAllDocs.resolves(this.docs)
  247. this.filter = { lines: true }
  248. this.result = await this.DocManager.getAllNonDeletedDocs(
  249. this.project_id,
  250. this.filter
  251. )
  252. })
  253. it('should get the project from the database', function () {
  254. this.MongoManager.getProjectsDocs.should.have.been.calledWith(
  255. this.project_id,
  256. { include_deleted: false },
  257. this.filter
  258. )
  259. })
  260. it('should return the docs', function () {
  261. expect(this.result).to.deep.equal(this.docs)
  262. })
  263. })
  264. describe('when there are no docs for the project', function () {
  265. it('should return a NotFoundError', async function () {
  266. this.MongoManager.getProjectsDocs.resolves(null)
  267. this.DocArchiveManager.unArchiveAllDocs.resolves(null)
  268. await expect(
  269. this.DocManager.getAllNonDeletedDocs(this.project_id, this.filter)
  270. ).to.be.rejectedWith(`No docs for project ${this.project_id}`)
  271. })
  272. })
  273. })
  274. describe('patchDoc', function () {
  275. describe('when the doc exists', function () {
  276. beforeEach(function () {
  277. this.lines = ['mock', 'doc', 'lines']
  278. this.rev = 77
  279. this.MongoManager.findDoc.resolves({
  280. _id: new ObjectId(this.doc_id),
  281. })
  282. this.meta = {}
  283. })
  284. describe('standard path', function () {
  285. beforeEach(async function () {
  286. await this.DocManager.patchDoc(
  287. this.project_id,
  288. this.doc_id,
  289. this.meta
  290. )
  291. })
  292. it('should get the doc', function () {
  293. expect(this.MongoManager.findDoc).to.have.been.calledWith(
  294. this.project_id,
  295. this.doc_id
  296. )
  297. })
  298. it('should persist the meta', function () {
  299. expect(this.MongoManager.patchDoc).to.have.been.calledWith(
  300. this.project_id,
  301. this.doc_id,
  302. this.meta
  303. )
  304. })
  305. })
  306. describe('background flush disabled and deleting a doc', function () {
  307. beforeEach(async function () {
  308. this.settings.docstore.archiveOnSoftDelete = false
  309. this.meta.deleted = true
  310. await this.DocManager.patchDoc(
  311. this.project_id,
  312. this.doc_id,
  313. this.meta
  314. )
  315. })
  316. it('should not flush the doc out of mongo', function () {
  317. expect(this.DocArchiveManager.archiveDoc).to.not.have.been.called
  318. })
  319. })
  320. describe('background flush enabled and not deleting a doc', function () {
  321. beforeEach(async function () {
  322. this.settings.docstore.archiveOnSoftDelete = false
  323. this.meta.deleted = false
  324. await this.DocManager.patchDoc(
  325. this.project_id,
  326. this.doc_id,
  327. this.meta
  328. )
  329. })
  330. it('should not flush the doc out of mongo', function () {
  331. expect(this.DocArchiveManager.archiveDoc).to.not.have.been.called
  332. })
  333. })
  334. describe('background flush enabled and deleting a doc', function () {
  335. beforeEach(function () {
  336. this.settings.docstore.archiveOnSoftDelete = true
  337. this.meta.deleted = true
  338. })
  339. describe('when the background flush succeeds', function () {
  340. beforeEach(async function () {
  341. await this.DocManager.patchDoc(
  342. this.project_id,
  343. this.doc_id,
  344. this.meta
  345. )
  346. })
  347. it('should not log a warning', function () {
  348. expect(this.logger.warn).to.not.have.been.called
  349. })
  350. it('should flush the doc out of mongo', function () {
  351. expect(this.DocArchiveManager.archiveDoc).to.have.been.calledWith(
  352. this.project_id,
  353. this.doc_id
  354. )
  355. })
  356. })
  357. describe('when the background flush fails', function () {
  358. beforeEach(async function () {
  359. this.err = new Error('foo')
  360. this.DocArchiveManager.archiveDoc.rejects(this.err)
  361. await this.DocManager.patchDoc(
  362. this.project_id,
  363. this.doc_id,
  364. this.meta
  365. )
  366. })
  367. it('should log a warning', function () {
  368. expect(this.logger.warn).to.have.been.calledWith(
  369. sinon.match({
  370. projectId: this.project_id,
  371. docId: this.doc_id,
  372. err: this.err,
  373. }),
  374. 'archiving a single doc in the background failed'
  375. )
  376. })
  377. })
  378. })
  379. })
  380. describe('when the doc does not exist', function () {
  381. it('should return a NotFoundError', async function () {
  382. this.MongoManager.findDoc.resolves(null)
  383. await expect(
  384. this.DocManager.patchDoc(this.project_id, this.doc_id, {})
  385. ).to.be.rejectedWith(
  386. `No such project/doc to delete: ${this.project_id}/${this.doc_id}`
  387. )
  388. })
  389. })
  390. })
  391. describe('updateDoc', function () {
  392. beforeEach(function () {
  393. this.oldDocLines = ['old', 'doc', 'lines']
  394. this.newDocLines = ['new', 'doc', 'lines']
  395. this.originalRanges = {
  396. changes: [
  397. {
  398. id: new ObjectId().toString(),
  399. op: { i: 'foo', p: 3 },
  400. meta: {
  401. user_id: new ObjectId().toString(),
  402. ts: new Date().toString(),
  403. },
  404. },
  405. ],
  406. }
  407. this.newRanges = {
  408. changes: [
  409. {
  410. id: new ObjectId().toString(),
  411. op: { i: 'bar', p: 6 },
  412. meta: {
  413. user_id: new ObjectId().toString(),
  414. ts: new Date().toString(),
  415. },
  416. },
  417. ],
  418. }
  419. this.version = 42
  420. this.doc = {
  421. _id: this.doc_id,
  422. project_id: this.project_id,
  423. lines: this.oldDocLines,
  424. rev: (this.rev = 5),
  425. version: this.version,
  426. ranges: this.originalRanges,
  427. }
  428. this.DocManager._getDoc = sinon.stub()
  429. })
  430. describe('when only the doc lines have changed', function () {
  431. beforeEach(async function () {
  432. this.DocManager._getDoc = sinon.stub().resolves(this.doc)
  433. this.result = await this.DocManager.updateDoc(
  434. this.project_id,
  435. this.doc_id,
  436. this.newDocLines,
  437. this.version,
  438. this.originalRanges
  439. )
  440. })
  441. it('should get the existing doc', function () {
  442. this.DocManager._getDoc
  443. .calledWith(this.project_id, this.doc_id, {
  444. version: true,
  445. rev: true,
  446. lines: true,
  447. ranges: true,
  448. inS3: true,
  449. })
  450. .should.equal(true)
  451. })
  452. it('should upsert the document to the doc collection', function () {
  453. this.MongoManager.upsertIntoDocCollection
  454. .calledWith(this.project_id, this.doc_id, this.rev, {
  455. lines: this.newDocLines,
  456. })
  457. .should.equal(true)
  458. })
  459. it('should return the new rev', function () {
  460. expect(this.result).to.deep.equal({ modified: true, rev: this.rev + 1 })
  461. })
  462. })
  463. describe('when the doc ranges have changed', function () {
  464. beforeEach(async function () {
  465. this.DocManager._getDoc = sinon.stub().resolves(this.doc)
  466. this.RangeManager.shouldUpdateRanges.returns(true)
  467. this.result = await this.DocManager.updateDoc(
  468. this.project_id,
  469. this.doc_id,
  470. this.oldDocLines,
  471. this.version,
  472. this.newRanges
  473. )
  474. })
  475. it('should upsert the ranges', function () {
  476. this.MongoManager.upsertIntoDocCollection
  477. .calledWith(this.project_id, this.doc_id, this.rev, {
  478. ranges: this.newRanges,
  479. })
  480. .should.equal(true)
  481. })
  482. it('should return the new rev', function () {
  483. expect(this.result).to.deep.equal({ modified: true, rev: this.rev + 1 })
  484. })
  485. })
  486. describe('when only the version has changed', function () {
  487. beforeEach(async function () {
  488. this.DocManager._getDoc = sinon.stub().resolves(this.doc)
  489. this.result = await this.DocManager.updateDoc(
  490. this.project_id,
  491. this.doc_id,
  492. this.oldDocLines,
  493. this.version + 1,
  494. this.originalRanges
  495. )
  496. })
  497. it('should update the version', function () {
  498. this.MongoManager.upsertIntoDocCollection.should.have.been.calledWith(
  499. this.project_id,
  500. this.doc_id,
  501. this.rev,
  502. { version: this.version + 1 }
  503. )
  504. })
  505. it('should return the old rev', function () {
  506. expect(this.result).to.deep.equal({ modified: true, rev: this.rev })
  507. })
  508. })
  509. describe('when the doc has not changed at all', function () {
  510. beforeEach(async function () {
  511. this.DocManager._getDoc = sinon.stub().resolves(this.doc)
  512. this.result = await this.DocManager.updateDoc(
  513. this.project_id,
  514. this.doc_id,
  515. this.oldDocLines,
  516. this.version,
  517. this.originalRanges
  518. )
  519. })
  520. it('should not update the ranges or lines or version', function () {
  521. this.MongoManager.upsertIntoDocCollection.called.should.equal(false)
  522. })
  523. it('should return the old rev and modified == false', function () {
  524. expect(this.result).to.deep.equal({ modified: false, rev: this.rev })
  525. })
  526. })
  527. describe('when the version is null', function () {
  528. it('should return an error', async function () {
  529. await expect(
  530. this.DocManager.updateDoc(
  531. this.project_id,
  532. this.doc_id,
  533. this.newDocLines,
  534. null,
  535. this.originalRanges
  536. )
  537. ).to.be.rejectedWith('no lines, version or ranges provided')
  538. })
  539. })
  540. describe('when the lines are null', function () {
  541. it('should return an error', async function () {
  542. await expect(
  543. this.DocManager.updateDoc(
  544. this.project_id,
  545. this.doc_id,
  546. null,
  547. this.version,
  548. this.originalRanges
  549. )
  550. ).to.be.rejectedWith('no lines, version or ranges provided')
  551. })
  552. })
  553. describe('when the ranges are null', function () {
  554. it('should return an error', async function () {
  555. await expect(
  556. this.DocManager.updateDoc(
  557. this.project_id,
  558. this.doc_id,
  559. this.newDocLines,
  560. this.version,
  561. null
  562. )
  563. ).to.be.rejectedWith('no lines, version or ranges provided')
  564. })
  565. })
  566. describe('when there is a generic error getting the doc', function () {
  567. beforeEach(async function () {
  568. this.error = new Error('doc could not be found')
  569. this.DocManager._getDoc = sinon.stub().rejects(this.error)
  570. await expect(
  571. this.DocManager.updateDoc(
  572. this.project_id,
  573. this.doc_id,
  574. this.newDocLines,
  575. this.version,
  576. this.originalRanges
  577. )
  578. ).to.be.rejectedWith(this.error)
  579. })
  580. it('should not upsert the document to the doc collection', function () {
  581. this.MongoManager.upsertIntoDocCollection.should.not.have.been.called
  582. })
  583. })
  584. describe('when the version was decremented', function () {
  585. it('should return an error', async function () {
  586. this.DocManager._getDoc = sinon.stub().resolves(this.doc)
  587. await expect(
  588. this.DocManager.updateDoc(
  589. this.project_id,
  590. this.doc_id,
  591. this.newDocLines,
  592. this.version - 1,
  593. this.originalRanges
  594. )
  595. ).to.be.rejectedWith(Errors.DocVersionDecrementedError)
  596. })
  597. })
  598. describe('when the doc lines have not changed', function () {
  599. beforeEach(async function () {
  600. this.DocManager._getDoc = sinon.stub().resolves(this.doc)
  601. this.result = await this.DocManager.updateDoc(
  602. this.project_id,
  603. this.doc_id,
  604. this.oldDocLines.slice(),
  605. this.version,
  606. this.originalRanges
  607. )
  608. })
  609. it('should not update the doc', function () {
  610. this.MongoManager.upsertIntoDocCollection.called.should.equal(false)
  611. })
  612. it('should return the existing rev', function () {
  613. expect(this.result).to.deep.equal({ modified: false, rev: this.rev })
  614. })
  615. })
  616. describe('when the doc does not exist', function () {
  617. beforeEach(async function () {
  618. this.DocManager._getDoc = sinon.stub().resolves(null)
  619. this.result = await this.DocManager.updateDoc(
  620. this.project_id,
  621. this.doc_id,
  622. this.newDocLines,
  623. this.version,
  624. this.originalRanges
  625. )
  626. })
  627. it('should upsert the document to the doc collection', function () {
  628. this.MongoManager.upsertIntoDocCollection.should.have.been.calledWith(
  629. this.project_id,
  630. this.doc_id,
  631. undefined,
  632. {
  633. lines: this.newDocLines,
  634. ranges: this.originalRanges,
  635. version: this.version,
  636. }
  637. )
  638. })
  639. it('should return the new rev', function () {
  640. expect(this.result).to.deep.equal({ modified: true, rev: 1 })
  641. })
  642. })
  643. describe('when another update is racing', function () {
  644. beforeEach(async function () {
  645. this.DocManager._getDoc = sinon.stub().resolves(this.doc)
  646. this.MongoManager.upsertIntoDocCollection
  647. .onFirstCall()
  648. .rejects(new Errors.DocRevValueError())
  649. this.RangeManager.shouldUpdateRanges.returns(true)
  650. this.result = await this.DocManager.updateDoc(
  651. this.project_id,
  652. this.doc_id,
  653. this.newDocLines,
  654. this.version + 1,
  655. this.newRanges
  656. )
  657. })
  658. it('should upsert the doc twice', function () {
  659. this.MongoManager.upsertIntoDocCollection.should.have.been.calledWith(
  660. this.project_id,
  661. this.doc_id,
  662. this.rev,
  663. {
  664. ranges: this.newRanges,
  665. lines: this.newDocLines,
  666. version: this.version + 1,
  667. }
  668. )
  669. this.MongoManager.upsertIntoDocCollection.should.have.been.calledTwice
  670. })
  671. it('should return the new rev', function () {
  672. expect(this.result).to.deep.equal({ modified: true, rev: this.rev + 1 })
  673. })
  674. })
  675. })
  676. })