DocArchiveManager.test.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  1. import sinon from 'sinon'
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { ObjectId } from 'mongodb-legacy'
  4. import Errors from '../../../app/js/Errors.js'
  5. import * as StreamToBuffer from '../../../app/js/StreamToBuffer.js'
  6. const modulePath = '../../../app/js/DocArchiveManager.js'
  7. describe('DocArchiveManager', () => {
  8. let DocArchiveManager,
  9. PersistorManager,
  10. MongoManager,
  11. RangeManager,
  12. Settings,
  13. Crypto,
  14. StreamUtils,
  15. HashDigest,
  16. HashUpdate,
  17. archivedDocs,
  18. mongoDocs,
  19. archivedDoc,
  20. archivedDocJson,
  21. md5Sum,
  22. projectId,
  23. readStream,
  24. stream,
  25. streamToBuffer
  26. beforeEach(async () => {
  27. md5Sum = 'decafbad'
  28. RangeManager = {
  29. jsonRangesToMongo: sinon.stub().returns({ mongo: 'ranges' }),
  30. fixCommentIds: sinon.stub(),
  31. }
  32. Settings = {
  33. docstore: {
  34. backend: 'gcs',
  35. bucket: 'wombat',
  36. },
  37. parallelArchiveJobs: 3,
  38. }
  39. HashDigest = sinon.stub().returns(md5Sum)
  40. HashUpdate = sinon.stub().returns({ digest: HashDigest })
  41. Crypto = {
  42. createHash: sinon.stub().returns({ update: HashUpdate }),
  43. }
  44. StreamUtils = {
  45. ReadableString: sinon.stub().returns({ stream: 'readStream' }),
  46. }
  47. projectId = new ObjectId()
  48. archivedDocs = [
  49. {
  50. _id: new ObjectId(),
  51. inS3: true,
  52. rev: 2,
  53. },
  54. {
  55. _id: new ObjectId(),
  56. inS3: true,
  57. rev: 4,
  58. },
  59. {
  60. _id: new ObjectId(),
  61. inS3: true,
  62. rev: 6,
  63. },
  64. ]
  65. mongoDocs = [
  66. {
  67. _id: new ObjectId(),
  68. lines: ['one', 'two', 'three'],
  69. rev: 2,
  70. },
  71. {
  72. _id: new ObjectId(),
  73. lines: ['aaa', 'bbb', 'ccc'],
  74. rev: 4,
  75. },
  76. {
  77. _id: new ObjectId(),
  78. inS3: true,
  79. rev: 6,
  80. },
  81. {
  82. _id: new ObjectId(),
  83. inS3: true,
  84. rev: 6,
  85. },
  86. {
  87. _id: new ObjectId(),
  88. lines: ['111', '222', '333'],
  89. rev: 6,
  90. },
  91. ]
  92. archivedDoc = {
  93. lines: mongoDocs[0].lines,
  94. rev: mongoDocs[0].rev,
  95. }
  96. archivedDocJson = JSON.stringify({ ...archivedDoc, schema_v: 1 })
  97. stream = {
  98. on: sinon.stub(),
  99. resume: sinon.stub(),
  100. }
  101. stream.on.withArgs('data').yields(Buffer.from(archivedDocJson, 'utf8'))
  102. stream.on.withArgs('end').yields()
  103. readStream = {
  104. stream: 'readStream',
  105. }
  106. PersistorManager = {
  107. getObjectStream: sinon.stub().resolves(stream),
  108. sendStream: sinon.stub().resolves(),
  109. getObjectMd5Hash: sinon.stub().resolves(md5Sum),
  110. deleteObject: sinon.stub().resolves(),
  111. deleteDirectory: sinon.stub().resolves(),
  112. }
  113. const getNonArchivedProjectDocIds = sinon.stub()
  114. getNonArchivedProjectDocIds
  115. .onCall(0)
  116. .resolves(mongoDocs.filter(doc => !doc.inS3).map(doc => doc._id))
  117. getNonArchivedProjectDocIds.onCall(1).resolves([])
  118. const getArchivedProjectDocs = sinon.stub()
  119. getArchivedProjectDocs.onCall(0).resolves(archivedDocs)
  120. getArchivedProjectDocs.onCall(1).resolves([])
  121. const fakeGetDoc = async (_projectId, _docId) => {
  122. if (_projectId.equals(projectId)) {
  123. for (const mongoDoc of mongoDocs.concat(archivedDocs)) {
  124. if (mongoDoc._id.equals(_docId)) {
  125. return mongoDoc
  126. }
  127. }
  128. }
  129. throw new Errors.NotFoundError()
  130. }
  131. MongoManager = {
  132. markDocAsArchived: sinon.stub().resolves(),
  133. restoreArchivedDoc: sinon.stub().resolves(),
  134. upsertIntoDocCollection: sinon.stub().resolves(),
  135. getProjectsDocs: sinon.stub().resolves(mongoDocs),
  136. getNonDeletedArchivedProjectDocs: getArchivedProjectDocs,
  137. getNonArchivedProjectDocIds,
  138. getArchivedProjectDocs,
  139. findDoc: sinon.stub().callsFake(fakeGetDoc),
  140. getDocForArchiving: sinon.stub().callsFake(fakeGetDoc),
  141. destroyProject: sinon.stub().resolves(),
  142. }
  143. // Wrap streamToBuffer so that we can pass in something that it expects (in
  144. // this case, a Promise) rather than a stubbed stream object
  145. streamToBuffer = {
  146. streamToBuffer: async () => {
  147. const inputStream = new Promise(resolve => {
  148. stream.on('data', data => resolve(data))
  149. })
  150. const value = await StreamToBuffer.streamToBuffer(
  151. 'testProjectId',
  152. 'testDocId',
  153. inputStream
  154. )
  155. return value
  156. },
  157. }
  158. vi.doMock('@overleaf/settings', () => ({
  159. default: Settings,
  160. }))
  161. vi.doMock('crypto', () => ({
  162. default: Crypto,
  163. }))
  164. vi.doMock('@overleaf/stream-utils', () => StreamUtils)
  165. vi.doMock('../../../app/js/MongoManager', () => ({
  166. default: MongoManager,
  167. }))
  168. vi.doMock('../../../app/js/RangeManager', () => ({
  169. default: RangeManager,
  170. }))
  171. vi.doMock('../../../app/js/PersistorManager', () => ({
  172. default: PersistorManager,
  173. }))
  174. vi.doMock('../../../app/js/Errors', () => ({
  175. default: Errors,
  176. }))
  177. vi.doMock('../../../app/js/StreamToBuffer', () => streamToBuffer)
  178. DocArchiveManager = (await import(modulePath)).default
  179. })
  180. describe('archiveDoc', () => {
  181. it('should resolve when passed a valid document', async () => {
  182. await expect(DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)).to
  183. .eventually.be.fulfilled
  184. })
  185. it('should fix comment ids', async () => {
  186. await DocArchiveManager.archiveDoc(projectId, mongoDocs[1]._id)
  187. expect(RangeManager.fixCommentIds).to.have.been.called
  188. })
  189. it('should throw an error if the doc has no lines', async () => {
  190. const doc = mongoDocs[0]
  191. doc.lines = null
  192. await expect(
  193. DocArchiveManager.archiveDoc(projectId, doc._id)
  194. ).to.eventually.be.rejectedWith('doc has no lines')
  195. })
  196. it('should add the schema version', async () => {
  197. await DocArchiveManager.archiveDoc(projectId, mongoDocs[1]._id)
  198. expect(StreamUtils.ReadableString).to.have.been.calledWith(
  199. sinon.match(/"schema_v":1/)
  200. )
  201. })
  202. it('should calculate the hex md5 sum of the content', async () => {
  203. await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
  204. expect(Crypto.createHash).to.have.been.calledWith('md5')
  205. expect(HashUpdate).to.have.been.calledWith(archivedDocJson)
  206. expect(HashDigest).to.have.been.calledWith('hex')
  207. })
  208. it('should pass the md5 hash to the object persistor for verification', async () => {
  209. await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
  210. expect(PersistorManager.sendStream).to.have.been.calledWith(
  211. sinon.match.any,
  212. sinon.match.any,
  213. sinon.match.any,
  214. { sourceMd5: md5Sum }
  215. )
  216. })
  217. describe('with S3 persistor', () => {
  218. beforeEach(async () => {
  219. Settings.docstore.backend = 's3'
  220. await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
  221. })
  222. it('should not calculate the hex md5 sum of the content', () => {
  223. expect(Crypto.createHash).not.to.have.been.called
  224. expect(HashUpdate).not.to.have.been.called
  225. expect(HashDigest).not.to.have.been.called
  226. })
  227. it('should not pass an md5 hash to the object persistor for verification', () => {
  228. expect(PersistorManager.sendStream).not.to.have.been.calledWithMatch({
  229. sourceMd5: sinon.match.any,
  230. })
  231. })
  232. })
  233. it('should pass the correct bucket and key to the persistor', async () => {
  234. await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
  235. expect(PersistorManager.sendStream).to.have.been.calledWith(
  236. Settings.docstore.bucket,
  237. `${projectId}/${mongoDocs[0]._id}`
  238. )
  239. })
  240. it('should create a stream from the encoded json and send it', async () => {
  241. await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
  242. expect(StreamUtils.ReadableString).to.have.been.calledWith(
  243. archivedDocJson
  244. )
  245. expect(PersistorManager.sendStream).to.have.been.calledWith(
  246. sinon.match.any,
  247. sinon.match.any,
  248. readStream
  249. )
  250. })
  251. it('should mark the doc as archived', async () => {
  252. await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
  253. expect(MongoManager.markDocAsArchived).to.have.been.calledWith(
  254. projectId,
  255. mongoDocs[0]._id,
  256. mongoDocs[0].rev
  257. )
  258. })
  259. describe('when archiving is not configured', () => {
  260. beforeEach(() => {
  261. Settings.docstore.backend = undefined
  262. })
  263. it('should bail out early', async () => {
  264. await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
  265. expect(MongoManager.getDocForArchiving).to.not.have.been.called
  266. })
  267. })
  268. describe('with null bytes in the result', () => {
  269. const _stringify = JSON.stringify
  270. beforeEach(() => {
  271. JSON.stringify = sinon.stub().returns('{"bad": "\u0000"}')
  272. })
  273. afterEach(() => {
  274. JSON.stringify = _stringify
  275. })
  276. it('should return an error', async () => {
  277. await expect(
  278. DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
  279. ).to.eventually.be.rejectedWith('null bytes detected')
  280. })
  281. })
  282. })
  283. describe('unarchiveDoc', () => {
  284. let docId, lines, rev
  285. describe('when the doc is in S3', () => {
  286. beforeEach(() => {
  287. MongoManager.findDoc = sinon.stub().resolves({ inS3: true, rev })
  288. docId = mongoDocs[0]._id
  289. lines = ['doc', 'lines']
  290. rev = 123
  291. })
  292. it('should resolve when passed a valid document', async () => {
  293. await expect(DocArchiveManager.unarchiveDoc(projectId, docId)).to
  294. .eventually.be.fulfilled
  295. })
  296. it('should test md5 validity with the raw buffer', async () => {
  297. await DocArchiveManager.unarchiveDoc(projectId, docId)
  298. expect(HashUpdate).to.have.been.calledWith(
  299. sinon.match.instanceOf(Buffer)
  300. )
  301. })
  302. it('should throw an error if the md5 does not match', async () => {
  303. PersistorManager.getObjectMd5Hash.resolves('badf00d')
  304. await expect(
  305. DocArchiveManager.unarchiveDoc(projectId, docId)
  306. ).to.eventually.be.rejected.and.be.instanceof(Errors.Md5MismatchError)
  307. })
  308. it('should restore the doc in Mongo', async () => {
  309. await DocArchiveManager.unarchiveDoc(projectId, docId)
  310. expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith(
  311. projectId,
  312. docId,
  313. archivedDoc
  314. )
  315. })
  316. describe('when archiving is not configured', () => {
  317. beforeEach(() => {
  318. Settings.docstore.backend = undefined
  319. })
  320. it('should error out on archived doc', async () => {
  321. await expect(
  322. DocArchiveManager.unarchiveDoc(projectId, docId)
  323. ).to.eventually.be.rejected.and.match(
  324. /found archived doc, but archiving backend is not configured/
  325. )
  326. })
  327. it('should return early on non-archived doc', async () => {
  328. MongoManager.findDoc = sinon.stub().resolves({ rev })
  329. await DocArchiveManager.unarchiveDoc(projectId, docId)
  330. expect(PersistorManager.getObjectMd5Hash).to.not.have.been.called
  331. })
  332. })
  333. describe('doc contents', () => {
  334. let archivedDoc
  335. describe('when the doc has the old schema', () => {
  336. beforeEach(() => {
  337. archivedDoc = lines
  338. archivedDocJson = JSON.stringify(archivedDoc)
  339. stream.on
  340. .withArgs('data')
  341. .yields(Buffer.from(archivedDocJson, 'utf8'))
  342. })
  343. it('should return the docs lines', async () => {
  344. await DocArchiveManager.unarchiveDoc(projectId, docId)
  345. expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith(
  346. projectId,
  347. docId,
  348. { lines, rev }
  349. )
  350. })
  351. })
  352. describe('with the new schema and ranges', () => {
  353. beforeEach(() => {
  354. archivedDoc = {
  355. lines,
  356. ranges: { json: 'ranges' },
  357. rev: 456,
  358. schema_v: 1,
  359. }
  360. archivedDocJson = JSON.stringify(archivedDoc)
  361. stream.on
  362. .withArgs('data')
  363. .yields(Buffer.from(archivedDocJson, 'utf8'))
  364. })
  365. it('should return the doc lines and ranges', async () => {
  366. await DocArchiveManager.unarchiveDoc(projectId, docId)
  367. expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith(
  368. projectId,
  369. docId,
  370. {
  371. lines,
  372. ranges: { mongo: 'ranges' },
  373. rev: 456,
  374. }
  375. )
  376. })
  377. })
  378. describe('with the new schema and no ranges', () => {
  379. beforeEach(() => {
  380. archivedDoc = { lines, rev: 456, schema_v: 1 }
  381. archivedDocJson = JSON.stringify(archivedDoc)
  382. stream.on
  383. .withArgs('data')
  384. .yields(Buffer.from(archivedDocJson, 'utf8'))
  385. })
  386. it('should return only the doc lines', async () => {
  387. await DocArchiveManager.unarchiveDoc(projectId, docId)
  388. expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith(
  389. projectId,
  390. docId,
  391. { lines, rev: 456 }
  392. )
  393. })
  394. })
  395. describe('with the new schema and no rev', () => {
  396. beforeEach(() => {
  397. archivedDoc = { lines, schema_v: 1 }
  398. archivedDocJson = JSON.stringify(archivedDoc)
  399. stream.on
  400. .withArgs('data')
  401. .yields(Buffer.from(archivedDocJson, 'utf8'))
  402. })
  403. it('should use the rev obtained from Mongo', async () => {
  404. await DocArchiveManager.unarchiveDoc(projectId, docId)
  405. expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith(
  406. projectId,
  407. docId,
  408. { lines, rev }
  409. )
  410. })
  411. })
  412. describe('with an unrecognised schema', () => {
  413. beforeEach(() => {
  414. archivedDoc = { lines, schema_v: 2 }
  415. archivedDocJson = JSON.stringify(archivedDoc)
  416. stream.on
  417. .withArgs('data')
  418. .yields(Buffer.from(archivedDocJson, 'utf8'))
  419. })
  420. it('should throw an error', async () => {
  421. await expect(
  422. DocArchiveManager.unarchiveDoc(projectId, docId)
  423. ).to.eventually.be.rejectedWith(
  424. "I don't understand the doc format in s3"
  425. )
  426. })
  427. })
  428. })
  429. })
  430. it('should not do anything if the file is already unarchived', async () => {
  431. MongoManager.findDoc.resolves({ inS3: false })
  432. await DocArchiveManager.unarchiveDoc(projectId, docId)
  433. expect(PersistorManager.getObjectStream).not.to.have.been.called
  434. })
  435. it('should throw an error if the file is not found', async () => {
  436. PersistorManager.getObjectStream = sinon
  437. .stub()
  438. .rejects(new Errors.NotFoundError())
  439. await expect(
  440. DocArchiveManager.unarchiveDoc(projectId, docId)
  441. ).to.eventually.be.rejected.and.be.instanceof(Errors.NotFoundError)
  442. })
  443. })
  444. describe('destroyProject', () => {
  445. describe('when archiving is enabled', () => {
  446. beforeEach(async () => {
  447. await DocArchiveManager.destroyProject(projectId)
  448. })
  449. it('should delete the project in Mongo', () => {
  450. expect(MongoManager.destroyProject).to.have.been.calledWith(projectId)
  451. })
  452. it('should delete the project in the persistor', () => {
  453. expect(PersistorManager.deleteDirectory).to.have.been.calledWith(
  454. Settings.docstore.bucket,
  455. projectId
  456. )
  457. })
  458. })
  459. describe('when archiving is disabled', () => {
  460. beforeEach(async () => {
  461. Settings.docstore.backend = ''
  462. await DocArchiveManager.destroyProject(projectId)
  463. })
  464. it('should delete the project in Mongo', () => {
  465. expect(MongoManager.destroyProject).to.have.been.calledWith(projectId)
  466. })
  467. it('should not delete the project in the persistor', () => {
  468. expect(PersistorManager.deleteDirectory).not.to.have.been.called
  469. })
  470. })
  471. })
  472. describe('archiveAllDocs', () => {
  473. it('should resolve with valid arguments', async () => {
  474. await expect(DocArchiveManager.archiveAllDocs(projectId)).to.eventually.be
  475. .fulfilled
  476. })
  477. it('should archive all project docs which are not in s3', async () => {
  478. await DocArchiveManager.archiveAllDocs(projectId)
  479. // not inS3
  480. expect(MongoManager.markDocAsArchived).to.have.been.calledWith(
  481. projectId,
  482. mongoDocs[0]._id
  483. )
  484. expect(MongoManager.markDocAsArchived).to.have.been.calledWith(
  485. projectId,
  486. mongoDocs[1]._id
  487. )
  488. expect(MongoManager.markDocAsArchived).to.have.been.calledWith(
  489. projectId,
  490. mongoDocs[4]._id
  491. )
  492. // inS3
  493. expect(MongoManager.markDocAsArchived).not.to.have.been.calledWith(
  494. projectId,
  495. mongoDocs[2]._id
  496. )
  497. expect(MongoManager.markDocAsArchived).not.to.have.been.calledWith(
  498. projectId,
  499. mongoDocs[3]._id
  500. )
  501. })
  502. describe('when archiving is not configured', () => {
  503. beforeEach(() => {
  504. Settings.docstore.backend = undefined
  505. })
  506. it('should bail out early', async () => {
  507. await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
  508. expect(MongoManager.getNonArchivedProjectDocIds).to.not.have.been.called
  509. })
  510. })
  511. })
  512. describe('unArchiveAllDocs', () => {
  513. it('should resolve with valid arguments', async () => {
  514. await expect(DocArchiveManager.unArchiveAllDocs(projectId)).to.eventually
  515. .be.fulfilled
  516. })
  517. it('should unarchive all inS3 docs', async () => {
  518. await DocArchiveManager.unArchiveAllDocs(projectId)
  519. for (const doc of archivedDocs) {
  520. expect(PersistorManager.getObjectStream).to.have.been.calledWith(
  521. Settings.docstore.bucket,
  522. `${projectId}/${doc._id}`
  523. )
  524. }
  525. })
  526. describe('when archiving is not configured', () => {
  527. beforeEach(() => {
  528. Settings.docstore.backend = undefined
  529. })
  530. it('should bail out early', async () => {
  531. await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
  532. expect(MongoManager.getNonDeletedArchivedProjectDocs).to.not.have.been
  533. .called
  534. })
  535. })
  536. })
  537. })