DocArchiveManagerTests.js 15 KB

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