DocArchiveManagerTests.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. const sinon = require('sinon')
  2. const chai = require('chai')
  3. const { expect } = chai
  4. const modulePath = '../../../app/js/DocArchiveManager.js'
  5. const SandboxedModule = require('sandboxed-module')
  6. const { ObjectId } = require('mongodb')
  7. const Errors = require('../../../app/js/Errors')
  8. chai.use(require('chai-as-promised'))
  9. chai.use(require('sinon-chai'))
  10. describe('DocArchiveManager', function () {
  11. let DocArchiveManager,
  12. PersistorManager,
  13. MongoManager,
  14. RangeManager,
  15. Settings,
  16. Logger,
  17. Crypto,
  18. Streamifier,
  19. HashDigest,
  20. HashUpdate,
  21. archivedDocs,
  22. mongoDocs,
  23. docJson,
  24. md5Sum,
  25. projectId,
  26. readStream,
  27. stream
  28. beforeEach(function () {
  29. md5Sum = 'decafbad'
  30. RangeManager = {
  31. jsonRangesToMongo: sinon.stub().returns({ mongo: 'ranges' })
  32. }
  33. Settings = {
  34. docstore: {
  35. bucket: 'wombat'
  36. }
  37. }
  38. Logger = {
  39. log: sinon.stub(),
  40. err: sinon.stub()
  41. }
  42. HashDigest = sinon.stub().returns(md5Sum)
  43. HashUpdate = sinon.stub().returns({ digest: HashDigest })
  44. Crypto = {
  45. createHash: sinon.stub().returns({ update: HashUpdate })
  46. }
  47. Streamifier = {
  48. createReadStream: sinon.stub().returns({ stream: 'readStream' })
  49. }
  50. projectId = ObjectId()
  51. archivedDocs = [
  52. {
  53. _id: ObjectId(),
  54. inS3: true,
  55. rev: 2
  56. },
  57. {
  58. _id: ObjectId(),
  59. inS3: true,
  60. rev: 4
  61. },
  62. {
  63. _id: ObjectId(),
  64. inS3: true,
  65. rev: 6
  66. }
  67. ]
  68. mongoDocs = [
  69. {
  70. _id: ObjectId(),
  71. lines: ['one', 'two', 'three'],
  72. rev: 2
  73. },
  74. {
  75. _id: ObjectId(),
  76. lines: ['aaa', 'bbb', 'ccc'],
  77. rev: 4
  78. },
  79. {
  80. _id: ObjectId(),
  81. inS3: true,
  82. rev: 6
  83. },
  84. {
  85. _id: ObjectId(),
  86. inS3: true,
  87. rev: 6
  88. },
  89. {
  90. _id: ObjectId(),
  91. lines: ['111', '222', '333'],
  92. rev: 6
  93. }
  94. ]
  95. docJson = JSON.stringify({
  96. lines: mongoDocs[0].lines,
  97. ranges: mongoDocs[0].ranges,
  98. schema_v: 1
  99. })
  100. stream = {
  101. on: sinon.stub(),
  102. resume: sinon.stub()
  103. }
  104. stream.on.withArgs('data').yields(Buffer.from(docJson, 'utf8'))
  105. stream.on.withArgs('end').yields()
  106. readStream = {
  107. stream: 'readStream'
  108. }
  109. PersistorManager = {
  110. getObjectStream: sinon.stub().resolves(stream),
  111. sendStream: sinon.stub().resolves(),
  112. getObjectMd5Hash: sinon.stub().resolves(md5Sum),
  113. deleteObject: sinon.stub().resolves()
  114. }
  115. MongoManager = {
  116. promises: {
  117. markDocAsArchived: sinon.stub().resolves(),
  118. upsertIntoDocCollection: sinon.stub().resolves(),
  119. getProjectsDocs: sinon.stub().resolves(mongoDocs),
  120. getArchivedProjectDocs: sinon.stub().resolves(archivedDocs),
  121. findDoc: sinon.stub().rejects(new Errors.NotFoundError()),
  122. destroyDoc: sinon.stub().resolves()
  123. }
  124. }
  125. for (const mongoDoc of mongoDocs.concat(archivedDocs)) {
  126. MongoManager.promises.findDoc
  127. .withArgs(projectId, mongoDoc._id, sinon.match.any)
  128. .resolves(mongoDoc)
  129. }
  130. DocArchiveManager = SandboxedModule.require(modulePath, {
  131. requires: {
  132. 'settings-sharelatex': Settings,
  133. 'logger-sharelatex': Logger,
  134. crypto: Crypto,
  135. streamifier: Streamifier,
  136. './MongoManager': MongoManager,
  137. './RangeManager': RangeManager,
  138. './PersistorManager': PersistorManager,
  139. './Errors': Errors
  140. },
  141. globals: {
  142. console,
  143. JSON
  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 throw an error if the md5 does not match', async function () {
  236. PersistorManager.getObjectMd5Hash.resolves('badf00d')
  237. await expect(
  238. DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  239. ).to.eventually.be.rejected.and.be.instanceof(Errors.Md5MismatchError)
  240. })
  241. it('should update the doc lines in mongo', async function () {
  242. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  243. expect(
  244. MongoManager.promises.upsertIntoDocCollection
  245. ).to.have.been.calledWith(projectId, docId, {
  246. lines: mongoDocs[0].lines
  247. })
  248. })
  249. it('should delete the doc in s3', async function () {
  250. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  251. expect(PersistorManager.deleteObject).to.have.been.calledWith(
  252. Settings.docstore.bucket,
  253. `${projectId}/${docId}`
  254. )
  255. })
  256. describe('doc contents', function () {
  257. let mongoDoc, s3Doc
  258. describe('when the doc has the old schema', function () {
  259. beforeEach(function () {
  260. mongoDoc = {
  261. lines: ['doc', 'lines']
  262. }
  263. s3Doc = ['doc', 'lines']
  264. docJson = JSON.stringify(s3Doc)
  265. stream.on.withArgs('data').yields(Buffer.from(docJson, 'utf8'))
  266. })
  267. it('should return the docs lines', async function () {
  268. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  269. expect(
  270. MongoManager.promises.upsertIntoDocCollection
  271. ).to.have.been.calledWith(projectId, docId, mongoDoc)
  272. })
  273. })
  274. describe('with the new schema and ranges', function () {
  275. beforeEach(function () {
  276. s3Doc = {
  277. lines: ['doc', 'lines'],
  278. ranges: { json: 'ranges' },
  279. schema_v: 1
  280. }
  281. mongoDoc = {
  282. lines: ['doc', 'lines'],
  283. ranges: { mongo: 'ranges' }
  284. }
  285. docJson = JSON.stringify(s3Doc)
  286. stream.on.withArgs('data').yields(Buffer.from(docJson, 'utf8'))
  287. })
  288. it('should return the doc lines and ranges', async function () {
  289. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  290. expect(
  291. MongoManager.promises.upsertIntoDocCollection
  292. ).to.have.been.calledWith(projectId, docId, mongoDoc)
  293. })
  294. })
  295. describe('with the new schema and no ranges', function () {
  296. beforeEach(function () {
  297. s3Doc = {
  298. lines: ['doc', 'lines'],
  299. schema_v: 1
  300. }
  301. mongoDoc = {
  302. lines: ['doc', 'lines']
  303. }
  304. docJson = JSON.stringify(s3Doc)
  305. stream.on.withArgs('data').yields(Buffer.from(docJson, 'utf8'))
  306. })
  307. it('should return only the doc lines', async function () {
  308. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  309. expect(
  310. MongoManager.promises.upsertIntoDocCollection
  311. ).to.have.been.calledWith(projectId, docId, mongoDoc)
  312. })
  313. })
  314. describe('with an unrecognised schema', function () {
  315. beforeEach(function () {
  316. s3Doc = {
  317. lines: ['doc', 'lines'],
  318. schema_v: 2
  319. }
  320. docJson = JSON.stringify(s3Doc)
  321. stream.on.withArgs('data').yields(Buffer.from(docJson, 'utf8'))
  322. })
  323. it('should throw an error', async function () {
  324. await expect(
  325. DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  326. ).to.eventually.be.rejectedWith(
  327. "I don't understand the doc format in s3"
  328. )
  329. })
  330. })
  331. })
  332. })
  333. it('should not do anything if the file is already unarchived', async function () {
  334. MongoManager.promises.findDoc.resolves({ inS3: false })
  335. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  336. expect(PersistorManager.getObjectStream).not.to.have.been.called
  337. })
  338. describe('when the file is removed while we are processing it', function () {
  339. beforeEach(function () {
  340. MongoManager.promises.findDoc = sinon.stub().resolves({ inS3: true })
  341. MongoManager.promises.findDoc.onSecondCall().resolves({ inS3: false })
  342. })
  343. it('should not throw an error if the file is unarchived before we get for its hash', async function () {
  344. PersistorManager.getObjectMd5Hash = sinon
  345. .stub()
  346. .rejects(new Errors.NotFoundError())
  347. await expect(DocArchiveManager.promises.unarchiveDoc(projectId, docId))
  348. .to.eventually.be.fulfilled
  349. expect(PersistorManager.getObjectStream).not.to.have.been.called
  350. })
  351. it('should not throw an error if the file is unarchived before we download it', async function () {
  352. PersistorManager.getObjectStream = sinon
  353. .stub()
  354. .rejects(new Errors.NotFoundError())
  355. await expect(DocArchiveManager.promises.unarchiveDoc(projectId, docId))
  356. .to.eventually.be.fulfilled
  357. expect(MongoManager.promises.upsertIntoDocCollection).not.to.have.been
  358. .called
  359. })
  360. })
  361. it('should throw an error if the file is not found but is still listed as archived', async function () {
  362. PersistorManager.getObjectStream = sinon
  363. .stub()
  364. .rejects(new Errors.NotFoundError())
  365. await expect(
  366. DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  367. ).to.eventually.be.rejected.and.be.instanceof(Errors.NotFoundError)
  368. })
  369. })
  370. describe('destroyDoc', function () {
  371. let docId
  372. beforeEach(function () {
  373. docId = mongoDocs[0]._id
  374. })
  375. it('should resolve when passed a valid document', async function () {
  376. await expect(DocArchiveManager.promises.destroyDoc(projectId, docId)).to
  377. .eventually.be.fulfilled
  378. })
  379. it('should throw a not found error when there is no document', async function () {
  380. await expect(
  381. DocArchiveManager.promises.destroyDoc(projectId, 'wombat')
  382. ).to.eventually.be.rejected.and.be.instanceof(Errors.NotFoundError)
  383. })
  384. describe('when the doc is in s3', function () {
  385. beforeEach(function () {
  386. mongoDocs[0].inS3 = true
  387. })
  388. it('should delete the document from s3, if it is in s3', async function () {
  389. await DocArchiveManager.promises.destroyDoc(projectId, docId)
  390. expect(PersistorManager.deleteObject).to.have.been.calledWith(
  391. Settings.docstore.bucket,
  392. `${projectId}/${docId}`
  393. )
  394. })
  395. it('should delete the doc in mongo', async function () {
  396. await DocArchiveManager.promises.destroyDoc(projectId, docId)
  397. })
  398. })
  399. describe('when the doc is not in s3', function () {
  400. beforeEach(function () {
  401. mongoDocs[0].inS3 = false
  402. })
  403. it('should not delete the document from s3, if it is not in s3', async function () {
  404. await DocArchiveManager.promises.destroyDoc(projectId, docId)
  405. expect(PersistorManager.deleteObject).not.to.have.been.called
  406. })
  407. it('should delete the doc in mongo', async function () {
  408. await DocArchiveManager.promises.destroyDoc(projectId, docId)
  409. })
  410. })
  411. })
  412. describe('archiveAllDocs', function () {
  413. it('should resolve with valid arguments', async function () {
  414. await expect(DocArchiveManager.promises.archiveAllDocs(projectId)).to
  415. .eventually.be.fulfilled
  416. })
  417. it('should archive all project docs which are not in s3', async function () {
  418. await DocArchiveManager.promises.archiveAllDocs(projectId)
  419. // not inS3
  420. expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith(
  421. mongoDocs[0]._id
  422. )
  423. expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith(
  424. mongoDocs[1]._id
  425. )
  426. expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith(
  427. mongoDocs[4]._id
  428. )
  429. // inS3
  430. expect(
  431. MongoManager.promises.markDocAsArchived
  432. ).not.to.have.been.calledWith(mongoDocs[2]._id)
  433. expect(
  434. MongoManager.promises.markDocAsArchived
  435. ).not.to.have.been.calledWith(mongoDocs[3]._id)
  436. })
  437. it('should return error if the project has no docs', async function () {
  438. MongoManager.promises.getProjectsDocs.resolves(null)
  439. await expect(
  440. DocArchiveManager.promises.archiveAllDocs(projectId)
  441. ).to.eventually.be.rejected.and.be.instanceof(Errors.NotFoundError)
  442. })
  443. })
  444. describe('unArchiveAllDocs', function () {
  445. it('should resolve with valid arguments', async function () {
  446. await expect(DocArchiveManager.promises.unArchiveAllDocs(projectId)).to
  447. .eventually.be.fulfilled
  448. })
  449. it('should unarchive all inS3 docs', async function () {
  450. await DocArchiveManager.promises.unArchiveAllDocs(projectId)
  451. for (const doc of archivedDocs) {
  452. expect(PersistorManager.getObjectStream).to.have.been.calledWith(
  453. Settings.docstore.bucket,
  454. `${projectId}/${doc._id}`
  455. )
  456. }
  457. })
  458. it('should return error if the project has no docs', async function () {
  459. MongoManager.promises.getArchivedProjectDocs.resolves(null)
  460. await expect(
  461. DocArchiveManager.promises.unArchiveAllDocs(projectId)
  462. ).to.eventually.be.rejected.and.be.instanceof(Errors.NotFoundError)
  463. })
  464. })
  465. describe('destroyAllDocs', function () {
  466. it('should resolve with valid arguments', async function () {
  467. await expect(DocArchiveManager.promises.destroyAllDocs(projectId)).to
  468. .eventually.be.fulfilled
  469. })
  470. it('should delete all docs that are in s3 from s3', async function () {
  471. await DocArchiveManager.promises.destroyAllDocs(projectId)
  472. // not inS3
  473. for (const index of [0, 1, 4]) {
  474. expect(PersistorManager.deleteObject).not.to.have.been.calledWith(
  475. Settings.docstore.bucket,
  476. `${projectId}/${mongoDocs[index]._id}`
  477. )
  478. }
  479. // inS3
  480. for (const index of [2, 3]) {
  481. expect(PersistorManager.deleteObject).to.have.been.calledWith(
  482. Settings.docstore.bucket,
  483. `${projectId}/${mongoDocs[index]._id}`
  484. )
  485. }
  486. })
  487. it('should destroy all docs in mongo', async function () {
  488. await DocArchiveManager.promises.destroyAllDocs(projectId)
  489. for (const mongoDoc of mongoDocs) {
  490. expect(MongoManager.promises.destroyDoc).to.have.been.calledWith(
  491. mongoDoc._id
  492. )
  493. }
  494. })
  495. })
  496. })