DocArchiveManagerTests.js 19 KB

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