DocArchiveManagerTests.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  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().resolves(),
  122. destroyDoc: sinon.stub().resolves()
  123. }
  124. }
  125. for (const mongoDoc of mongoDocs) {
  126. MongoManager.promises.findDoc
  127. .withArgs(projectId, mongoDoc._id)
  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. beforeEach(function () {
  227. docId = mongoDocs[0]._id
  228. })
  229. it('should resolve when passed a valid document', async function () {
  230. await expect(DocArchiveManager.promises.unarchiveDoc(projectId, docId)).to
  231. .eventually.be.fulfilled
  232. })
  233. it('should throw an error if the md5 does not match', async function () {
  234. PersistorManager.getObjectMd5Hash.resolves('badf00d')
  235. await expect(
  236. DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  237. ).to.eventually.be.rejected.and.be.instanceof(Errors.Md5MismatchError)
  238. })
  239. it('should update the doc lines in mongo', async function () {
  240. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  241. expect(
  242. MongoManager.promises.upsertIntoDocCollection
  243. ).to.have.been.calledWith(projectId, docId, { lines: mongoDocs[0].lines })
  244. })
  245. it('should delete the doc in s3', async function () {
  246. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  247. expect(PersistorManager.deleteObject).to.have.been.calledWith(
  248. Settings.docstore.bucket,
  249. `${projectId}/${docId}`
  250. )
  251. })
  252. describe('doc contents', function () {
  253. let mongoDoc, s3Doc
  254. describe('when the doc has the old schema', function () {
  255. beforeEach(function () {
  256. mongoDoc = {
  257. lines: ['doc', 'lines']
  258. }
  259. s3Doc = ['doc', 'lines']
  260. docJson = JSON.stringify(s3Doc)
  261. stream.on.withArgs('data').yields(Buffer.from(docJson, 'utf8'))
  262. })
  263. it('should return the docs lines', async function () {
  264. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  265. expect(
  266. MongoManager.promises.upsertIntoDocCollection
  267. ).to.have.been.calledWith(projectId, docId, mongoDoc)
  268. })
  269. })
  270. describe('with the new schema and ranges', function () {
  271. beforeEach(function () {
  272. s3Doc = {
  273. lines: ['doc', 'lines'],
  274. ranges: { json: 'ranges' },
  275. schema_v: 1
  276. }
  277. mongoDoc = {
  278. lines: ['doc', 'lines'],
  279. ranges: { mongo: 'ranges' }
  280. }
  281. docJson = JSON.stringify(s3Doc)
  282. stream.on.withArgs('data').yields(Buffer.from(docJson, 'utf8'))
  283. })
  284. it('should return the doc lines and ranges', async function () {
  285. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  286. expect(
  287. MongoManager.promises.upsertIntoDocCollection
  288. ).to.have.been.calledWith(projectId, docId, mongoDoc)
  289. })
  290. })
  291. describe('with the new schema and no ranges', function () {
  292. beforeEach(function () {
  293. s3Doc = {
  294. lines: ['doc', 'lines'],
  295. schema_v: 1
  296. }
  297. mongoDoc = {
  298. lines: ['doc', 'lines']
  299. }
  300. docJson = JSON.stringify(s3Doc)
  301. stream.on.withArgs('data').yields(Buffer.from(docJson, 'utf8'))
  302. })
  303. it('should return only the doc lines', async function () {
  304. await DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  305. expect(
  306. MongoManager.promises.upsertIntoDocCollection
  307. ).to.have.been.calledWith(projectId, docId, mongoDoc)
  308. })
  309. })
  310. describe('with an unrecognised schema', function () {
  311. beforeEach(function () {
  312. s3Doc = {
  313. lines: ['doc', 'lines'],
  314. schema_v: 2
  315. }
  316. docJson = JSON.stringify(s3Doc)
  317. stream.on.withArgs('data').yields(Buffer.from(docJson, 'utf8'))
  318. })
  319. it('should throw an error', async function () {
  320. await expect(
  321. DocArchiveManager.promises.unarchiveDoc(projectId, docId)
  322. ).to.eventually.be.rejectedWith(
  323. "I don't understand the doc format in s3"
  324. )
  325. })
  326. })
  327. })
  328. })
  329. describe('destroyDoc', function () {
  330. let docId
  331. beforeEach(function () {
  332. docId = mongoDocs[0]._id
  333. })
  334. it('should resolve when passed a valid document', async function () {
  335. await expect(DocArchiveManager.promises.destroyDoc(projectId, docId)).to
  336. .eventually.be.fulfilled
  337. })
  338. it('should throw a not found error when there is no document', async function () {
  339. await expect(
  340. DocArchiveManager.promises.destroyDoc(projectId, 'wombat')
  341. ).to.eventually.be.rejected.and.be.instanceof(Errors.NotFoundError)
  342. })
  343. describe('when the doc is in s3', function () {
  344. beforeEach(function () {
  345. mongoDocs[0].inS3 = true
  346. })
  347. it('should delete the document from s3, if it is in s3', async function () {
  348. await DocArchiveManager.promises.destroyDoc(projectId, docId)
  349. expect(PersistorManager.deleteObject).to.have.been.calledWith(
  350. Settings.docstore.bucket,
  351. `${projectId}/${docId}`
  352. )
  353. })
  354. it('should delete the doc in mongo', async function () {
  355. await DocArchiveManager.promises.destroyDoc(projectId, docId)
  356. })
  357. })
  358. describe('when the doc is not in s3', function () {
  359. beforeEach(function () {
  360. mongoDocs[0].inS3 = false
  361. })
  362. it('should not delete the document from s3, if it is not in s3', async function () {
  363. await DocArchiveManager.promises.destroyDoc(projectId, docId)
  364. expect(PersistorManager.deleteObject).not.to.have.been.called
  365. })
  366. it('should delete the doc in mongo', async function () {
  367. await DocArchiveManager.promises.destroyDoc(projectId, docId)
  368. })
  369. })
  370. })
  371. describe('archiveAllDocs', function () {
  372. it('should resolve with valid arguments', async function () {
  373. await expect(DocArchiveManager.promises.archiveAllDocs(projectId)).to
  374. .eventually.be.fulfilled
  375. })
  376. it('should archive all project docs which are not in s3', async function () {
  377. await DocArchiveManager.promises.archiveAllDocs(projectId)
  378. // not inS3
  379. expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith(
  380. mongoDocs[0]._id
  381. )
  382. expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith(
  383. mongoDocs[1]._id
  384. )
  385. expect(MongoManager.promises.markDocAsArchived).to.have.been.calledWith(
  386. mongoDocs[4]._id
  387. )
  388. // inS3
  389. expect(
  390. MongoManager.promises.markDocAsArchived
  391. ).not.to.have.been.calledWith(mongoDocs[2]._id)
  392. expect(
  393. MongoManager.promises.markDocAsArchived
  394. ).not.to.have.been.calledWith(mongoDocs[3]._id)
  395. })
  396. it('should return error if the project has no docs', async function () {
  397. MongoManager.promises.getProjectsDocs.resolves(null)
  398. await expect(
  399. DocArchiveManager.promises.archiveAllDocs(projectId)
  400. ).to.eventually.be.rejected.and.be.instanceof(Errors.NotFoundError)
  401. })
  402. })
  403. describe('unArchiveAllDocs', function () {
  404. it('should resolve with valid arguments', async function () {
  405. await expect(DocArchiveManager.promises.unArchiveAllDocs(projectId)).to
  406. .eventually.be.fulfilled
  407. })
  408. it('should unarchive all inS3 docs', async function () {
  409. await DocArchiveManager.promises.unArchiveAllDocs(projectId)
  410. for (const doc of archivedDocs) {
  411. expect(PersistorManager.getObjectStream).to.have.been.calledWith(
  412. Settings.docstore.bucket,
  413. `${projectId}/${doc._id}`
  414. )
  415. }
  416. })
  417. it('should return error if the project has no docs', async function () {
  418. MongoManager.promises.getArchivedProjectDocs.resolves(null)
  419. await expect(
  420. DocArchiveManager.promises.unArchiveAllDocs(projectId)
  421. ).to.eventually.be.rejected.and.be.instanceof(Errors.NotFoundError)
  422. })
  423. })
  424. describe('destroyAllDocs', function () {
  425. it('should resolve with valid arguments', async function () {
  426. await expect(DocArchiveManager.promises.destroyAllDocs(projectId)).to
  427. .eventually.be.fulfilled
  428. })
  429. it('should delete all docs that are in s3 from s3', async function () {
  430. await DocArchiveManager.promises.destroyAllDocs(projectId)
  431. // not inS3
  432. for (const index of [0, 1, 4]) {
  433. expect(PersistorManager.deleteObject).not.to.have.been.calledWith(
  434. Settings.docstore.bucket,
  435. `${projectId}/${mongoDocs[index]._id}`
  436. )
  437. }
  438. // inS3
  439. for (const index of [2, 3]) {
  440. expect(PersistorManager.deleteObject).to.have.been.calledWith(
  441. Settings.docstore.bucket,
  442. `${projectId}/${mongoDocs[index]._id}`
  443. )
  444. }
  445. })
  446. it('should destroy all docs in mongo', async function () {
  447. await DocArchiveManager.promises.destroyAllDocs(projectId)
  448. for (const mongoDoc of mongoDocs) {
  449. expect(MongoManager.promises.destroyDoc).to.have.been.calledWith(
  450. mongoDoc._id
  451. )
  452. }
  453. })
  454. })
  455. })