DocArchiveManagerTests.js 18 KB

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