DocArchiveManagerTests.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  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. archivedDoc,
  20. archivedDocJson,
  21. md5Sum,
  22. projectId,
  23. readStream,
  24. stream
  25. beforeEach(function () {
  26. md5Sum = 'decafbad'
  27. RangeManager = {
  28. jsonRangesToMongo: sinon.stub().returns({ mongo: 'ranges' }),
  29. }
  30. Settings = {
  31. docstore: {
  32. backend: 'gcs',
  33. bucket: 'wombat',
  34. },
  35. parallelArchiveJobs: 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. archivedDoc = {
  91. lines: mongoDocs[0].lines,
  92. rev: mongoDocs[0].rev,
  93. }
  94. archivedDocJson = JSON.stringify({ ...archivedDoc, schema_v: 1 })
  95. stream = {
  96. on: sinon.stub(),
  97. resume: sinon.stub(),
  98. }
  99. stream.on.withArgs('data').yields(Buffer.from(archivedDocJson, '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. deleteDirectory: sinon.stub().resolves(),
  110. }
  111. const getNonArchivedProjectDocs = sinon.stub()
  112. getNonArchivedProjectDocs
  113. .onCall(0)
  114. .resolves(mongoDocs.filter(doc => !doc.inS3))
  115. getNonArchivedProjectDocs.onCall(1).resolves([])
  116. const getArchivedProjectDocs = sinon.stub()
  117. getArchivedProjectDocs.onCall(0).resolves(archivedDocs)
  118. getArchivedProjectDocs.onCall(1).resolves([])
  119. MongoManager = {
  120. promises: {
  121. markDocAsArchived: sinon.stub().resolves(),
  122. restoreArchivedDoc: sinon.stub().resolves(),
  123. upsertIntoDocCollection: sinon.stub().resolves(),
  124. getProjectsDocs: sinon.stub().resolves(mongoDocs),
  125. getNonDeletedArchivedProjectDocs: getArchivedProjectDocs,
  126. getNonArchivedProjectDocs,
  127. getArchivedProjectDocs,
  128. findDoc: sinon.stub().rejects(new Errors.NotFoundError()),
  129. destroyProject: sinon.stub().resolves(),
  130. },
  131. }
  132. for (const mongoDoc of mongoDocs.concat(archivedDocs)) {
  133. MongoManager.promises.findDoc
  134. .withArgs(projectId, mongoDoc._id, sinon.match.any)
  135. .resolves(mongoDoc)
  136. }
  137. DocArchiveManager = SandboxedModule.require(modulePath, {
  138. requires: {
  139. '@overleaf/settings': Settings,
  140. crypto: Crypto,
  141. streamifier: Streamifier,
  142. './MongoManager': MongoManager,
  143. './RangeManager': RangeManager,
  144. './PersistorManager': PersistorManager,
  145. './Errors': Errors,
  146. },
  147. })
  148. })
  149. describe('archiveDoc', function () {
  150. it('should resolve when passed a valid document', async function () {
  151. await expect(
  152. DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0])
  153. ).to.eventually.be.fulfilled
  154. })
  155. it('should throw an error if the doc has no lines', async function () {
  156. const doc = mongoDocs[0]
  157. doc.lines = null
  158. await expect(
  159. DocArchiveManager.promises.archiveDoc(projectId, doc)
  160. ).to.eventually.be.rejectedWith('doc has no lines')
  161. })
  162. it('should add the schema version', async function () {
  163. await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[1])
  164. expect(Streamifier.createReadStream).to.have.been.calledWith(
  165. sinon.match(/"schema_v":1/)
  166. )
  167. })
  168. it('should calculate the hex md5 sum of the content', async function () {
  169. await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0])
  170. expect(Crypto.createHash).to.have.been.calledWith('md5')
  171. expect(HashUpdate).to.have.been.calledWith(archivedDocJson)
  172. expect(HashDigest).to.have.been.calledWith('hex')
  173. })
  174. it('should pass the md5 hash to the object persistor for verification', async function () {
  175. await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0])
  176. expect(PersistorManager.sendStream).to.have.been.calledWith(
  177. sinon.match.any,
  178. sinon.match.any,
  179. sinon.match.any,
  180. { sourceMd5: md5Sum }
  181. )
  182. })
  183. it('should pass the correct bucket and key to the persistor', async function () {
  184. await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0])
  185. expect(PersistorManager.sendStream).to.have.been.calledWith(
  186. Settings.docstore.bucket,
  187. `${projectId}/${mongoDocs[0]._id}`
  188. )
  189. })
  190. it('should create a stream from the encoded json and send it', async function () {
  191. await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0])
  192. expect(Streamifier.createReadStream).to.have.been.calledWith(
  193. archivedDocJson
  194. )
  195. expect(PersistorManager.sendStream).to.have.been.calledWith(
  196. sinon.match.any,
  197. sinon.match.any,
  198. readStream
  199. )
  200. })
  201. it('should mark the doc as archived', async function () {
  202. await DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0])
  203. expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith(
  204. mongoDocs[0]._id,
  205. mongoDocs[0].rev
  206. )
  207. })
  208. describe('with null bytes in the result', function () {
  209. const _stringify = JSON.stringify
  210. beforeEach(function () {
  211. JSON.stringify = sinon.stub().returns('{"bad": "\u0000"}')
  212. })
  213. afterEach(function () {
  214. JSON.stringify = _stringify
  215. })
  216. it('should return an error', async function () {
  217. await expect(
  218. DocArchiveManager.promises.archiveDoc(projectId, mongoDocs[0])
  219. ).to.eventually.be.rejectedWith('null bytes detected')
  220. })
  221. })
  222. })
  223. describe('unarchiveDoc', function () {
  224. let docId, lines, rev
  225. describe('when the doc is in S3', function () {
  226. beforeEach(function () {
  227. MongoManager.promises.findDoc = sinon
  228. .stub()
  229. .resolves({ inS3: true, rev })
  230. docId = mongoDocs[0]._id
  231. lines = ['doc', 'lines']
  232. rev = 123
  233. })
  234. it('should resolve when passed a valid document', async function () {
  235. await expect(DocArchiveManager.promises.unarchiveDoc(projectId, docId))
  236. .to.eventually.be.fulfilled
  237. })
  238. it('should test md5 validity with the raw buffer', async function () {
  239. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  240. expect(HashUpdate).to.have.been.calledWith(
  241. sinon.match.instanceOf(Buffer)
  242. )
  243. })
  244. it('should throw an error if the md5 does not match', async function () {
  245. PersistorManager.getObjectMd5Hash.resolves('badf00d')
  246. await expect(
  247. DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  248. ).to.eventually.be.rejected.and.be.instanceof(Errors.Md5MismatchError)
  249. })
  250. it('should restore the doc in Mongo', async function () {
  251. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  252. expect(
  253. MongoManager.promises.restoreArchivedDoc
  254. ).to.have.been.calledWith(projectId, docId, archivedDoc)
  255. })
  256. describe('doc contents', function () {
  257. let archivedDoc
  258. describe('when the doc has the old schema', function () {
  259. beforeEach(function () {
  260. archivedDoc = lines
  261. archivedDocJson = JSON.stringify(archivedDoc)
  262. stream.on
  263. .withArgs('data')
  264. .yields(Buffer.from(archivedDocJson, 'utf8'))
  265. })
  266. it('should return the docs lines', async function () {
  267. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  268. expect(
  269. MongoManager.promises.restoreArchivedDoc
  270. ).to.have.been.calledWith(projectId, docId, { lines, rev })
  271. })
  272. })
  273. describe('with the new schema and ranges', function () {
  274. beforeEach(function () {
  275. archivedDoc = {
  276. lines,
  277. ranges: { json: 'ranges' },
  278. rev: 456,
  279. schema_v: 1,
  280. }
  281. archivedDocJson = JSON.stringify(archivedDoc)
  282. stream.on
  283. .withArgs('data')
  284. .yields(Buffer.from(archivedDocJson, 'utf8'))
  285. })
  286. it('should return the doc lines and ranges', async function () {
  287. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  288. expect(
  289. MongoManager.promises.restoreArchivedDoc
  290. ).to.have.been.calledWith(projectId, docId, {
  291. lines,
  292. ranges: { mongo: 'ranges' },
  293. rev: 456,
  294. })
  295. })
  296. })
  297. describe('with the new schema and no ranges', function () {
  298. beforeEach(function () {
  299. archivedDoc = { lines, rev: 456, schema_v: 1 }
  300. archivedDocJson = JSON.stringify(archivedDoc)
  301. stream.on
  302. .withArgs('data')
  303. .yields(Buffer.from(archivedDocJson, 'utf8'))
  304. })
  305. it('should return only the doc lines', async function () {
  306. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  307. expect(
  308. MongoManager.promises.restoreArchivedDoc
  309. ).to.have.been.calledWith(projectId, docId, { lines, rev: 456 })
  310. })
  311. })
  312. describe('with the new schema and no rev', function () {
  313. beforeEach(function () {
  314. archivedDoc = { lines, schema_v: 1 }
  315. archivedDocJson = JSON.stringify(archivedDoc)
  316. stream.on
  317. .withArgs('data')
  318. .yields(Buffer.from(archivedDocJson, 'utf8'))
  319. })
  320. it('should use the rev obtained from Mongo', async function () {
  321. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  322. expect(
  323. MongoManager.promises.restoreArchivedDoc
  324. ).to.have.been.calledWith(projectId, docId, { lines, rev })
  325. })
  326. })
  327. describe('with an unrecognised schema', function () {
  328. beforeEach(function () {
  329. archivedDoc = { lines, schema_v: 2 }
  330. archivedDocJson = JSON.stringify(archivedDoc)
  331. stream.on
  332. .withArgs('data')
  333. .yields(Buffer.from(archivedDocJson, 'utf8'))
  334. })
  335. it('should throw an error', async function () {
  336. await expect(
  337. DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  338. ).to.eventually.be.rejectedWith(
  339. "I don't understand the doc format in s3"
  340. )
  341. })
  342. })
  343. })
  344. })
  345. it('should not do anything if the file is already unarchived', async function () {
  346. MongoManager.promises.findDoc.resolves({ inS3: false })
  347. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  348. expect(PersistorManager.getObjectStream).not.to.have.been.called
  349. })
  350. it('should throw an error if the file is not found', async function () {
  351. PersistorManager.getObjectStream = sinon
  352. .stub()
  353. .rejects(new Errors.NotFoundError())
  354. await expect(
  355. DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  356. ).to.eventually.be.rejected.and.be.instanceof(Errors.NotFoundError)
  357. })
  358. })
  359. describe('destroyProject', function () {
  360. describe('when archiving is enabled', function () {
  361. beforeEach(async function () {
  362. await DocArchiveManager.promises.destroyProject(projectId)
  363. })
  364. it('should delete the project in Mongo', function () {
  365. expect(MongoManager.promises.destroyProject).to.have.been.calledWith(
  366. projectId
  367. )
  368. })
  369. it('should delete the project in the persistor', function () {
  370. expect(PersistorManager.deleteDirectory).to.have.been.calledWith(
  371. Settings.docstore.bucket,
  372. projectId
  373. )
  374. })
  375. })
  376. describe('when archiving is disabled', function () {
  377. beforeEach(async function () {
  378. Settings.docstore.backend = ''
  379. await DocArchiveManager.promises.destroyProject(projectId)
  380. })
  381. it('should delete the project in Mongo', function () {
  382. expect(MongoManager.promises.destroyProject).to.have.been.calledWith(
  383. projectId
  384. )
  385. })
  386. it('should not delete the project in the persistor', function () {
  387. expect(PersistorManager.deleteDirectory).not.to.have.been.called
  388. })
  389. })
  390. })
  391. describe('archiveAllDocs', function () {
  392. it('should resolve with valid arguments', async function () {
  393. await expect(DocArchiveManager.promises.archiveAllDocs(projectId)).to
  394. .eventually.be.fulfilled
  395. })
  396. it('should archive all project docs which are not in s3', async function () {
  397. await DocArchiveManager.promises.archiveAllDocs(projectId)
  398. // not inS3
  399. expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith(
  400. mongoDocs[0]._id
  401. )
  402. expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith(
  403. mongoDocs[1]._id
  404. )
  405. expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith(
  406. mongoDocs[4]._id
  407. )
  408. // inS3
  409. expect(
  410. MongoManager.promises.markDocAsArchived
  411. ).not.to.have.been.calledWith(mongoDocs[2]._id)
  412. expect(
  413. MongoManager.promises.markDocAsArchived
  414. ).not.to.have.been.calledWith(mongoDocs[3]._id)
  415. })
  416. })
  417. describe('unArchiveAllDocs', function () {
  418. it('should resolve with valid arguments', async function () {
  419. await expect(DocArchiveManager.promises.unArchiveAllDocs(projectId)).to
  420. .eventually.be.fulfilled
  421. })
  422. it('should unarchive all inS3 docs', async function () {
  423. await DocArchiveManager.promises.unArchiveAllDocs(projectId)
  424. for (const doc of archivedDocs) {
  425. expect(PersistorManager.getObjectStream).to.have.been.calledWith(
  426. Settings.docstore.bucket,
  427. `${projectId}/${doc._id}`
  428. )
  429. }
  430. })
  431. })
  432. })