DocArchiveManagerTests.js 17 KB

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