ProjectDownloadsController.test.mjs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727
  1. import { vi } from 'vitest'
  2. // TODO: This file was created by bulk-decaffeinate.
  3. // Fix any style issues and re-enable lint.
  4. /*
  5. * decaffeinate suggestions:
  6. * DS102: Remove unnecessary code created because of implicit returns
  7. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  8. */
  9. import sinon from 'sinon'
  10. import MockRequest from '../helpers/MockRequest.mjs'
  11. import MockResponse from '../helpers/MockResponse.mjs'
  12. import { DocumentConversionError } from '../../../../app/src/Features/Errors/Errors.js'
  13. vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
  14. vi.importActual('../../../../app/src/Features/Errors/Errors.js')
  15. )
  16. const modulePath =
  17. '../../../../app/src/Features/Downloads/ProjectDownloadsController.mjs'
  18. describe('ProjectDownloadsController', function () {
  19. beforeEach(async function (ctx) {
  20. ctx.project_id = 'project-id-123'
  21. ctx.req = new MockRequest(vi)
  22. ctx.res = new MockResponse(vi)
  23. ctx.next = sinon.stub()
  24. ctx.DocumentUpdaterHandler = sinon.stub()
  25. vi.doMock(
  26. '../../../../app/src/Features/Downloads/ProjectZipStreamManager.mjs',
  27. () => ({
  28. default: (ctx.ProjectZipStreamManager = {}),
  29. })
  30. )
  31. vi.doMock('../../../../app/src/Features/Project/ProjectGetter.mjs', () => ({
  32. default: (ctx.ProjectGetter = {
  33. promises: {
  34. getProject: sinon.stub(),
  35. },
  36. }),
  37. }))
  38. vi.doMock(
  39. '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs',
  40. () => ({
  41. default: ctx.DocumentUpdaterHandler,
  42. })
  43. )
  44. vi.doMock(
  45. '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
  46. () => ({
  47. default: (ctx.ProjectAuditLogHandler = {
  48. addEntryInBackground: sinon.stub(),
  49. }),
  50. })
  51. )
  52. vi.doMock(
  53. '../../../../app/src/Features/Authentication/SessionManager.mjs',
  54. () => ({
  55. default: (ctx.SessionManager = {
  56. getLoggedInUserId: sinon
  57. .stub()
  58. .callsFake(session => session?.user?._id),
  59. }),
  60. })
  61. )
  62. vi.doMock(
  63. '../../../../app/src/Features/Uploads/DocumentConversionManager.mjs',
  64. () => ({
  65. default: (ctx.DocumentConversionManager = {
  66. promises: {
  67. convertProjectToDocument: sinon.stub(),
  68. streamConvertedProjectDocument: sinon.stub(),
  69. },
  70. }),
  71. })
  72. )
  73. vi.doMock(
  74. '../../../../app/src/Features/SplitTests/SplitTestHandler.mjs',
  75. () => ({
  76. default: (ctx.SplitTestHandler = {
  77. featureFlagEnabled: sinon.stub().yields(null, false),
  78. promises: {
  79. featureFlagEnabled: sinon.stub().resolves(false),
  80. },
  81. }),
  82. })
  83. )
  84. vi.doMock('@overleaf/settings', () => ({
  85. default: (ctx.Settings = {
  86. siteUrl: 'https://overleaf.example.com',
  87. }),
  88. }))
  89. vi.doMock('node:stream/promises', () => ({
  90. pipeline: (ctx.pipeline = sinon.stub().resolves()),
  91. }))
  92. vi.doMock(
  93. '../../../../app/src/Features/Analytics/AnalyticsManager.mjs',
  94. () => ({
  95. default: (ctx.AnalyticsManager = {
  96. recordEventForUserInBackground: sinon.stub(),
  97. }),
  98. })
  99. )
  100. ctx.ProjectDownloadsController = (await import(modulePath)).default
  101. })
  102. describe('downloadProject', function () {
  103. beforeEach(function (ctx) {
  104. ctx.stream = { pipe: sinon.stub() }
  105. ctx.ProjectZipStreamManager.createZipStreamForProject = sinon
  106. .stub()
  107. .yields(null, ctx.stream)
  108. ctx.req.params = { Project_id: ctx.project_id }
  109. ctx.req.ip = '192.168.1.1'
  110. ctx.req.session = {
  111. user: {
  112. _id: 'user-id-123',
  113. email: 'user@example.com',
  114. },
  115. }
  116. ctx.project_name = 'project name with accênts and % special characters'
  117. ctx.ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, {
  118. name: ctx.project_name,
  119. overleaf: { history: { id: 123 } },
  120. })
  121. ctx.DocumentUpdaterHandler.flushProjectToMongo = sinon
  122. .stub()
  123. .callsArgWith(1)
  124. ctx.Metrics.inc = sinon.stub()
  125. return ctx.ProjectDownloadsController.downloadProject(
  126. ctx.req,
  127. ctx.res,
  128. ctx.next
  129. )
  130. })
  131. it('should create a zip from the project', function (ctx) {
  132. return ctx.ProjectZipStreamManager.createZipStreamForProject
  133. .calledWith(ctx.project_id)
  134. .should.equal(true)
  135. })
  136. it('should stream the zip to the request', function (ctx) {
  137. return ctx.stream.pipe.calledWith(ctx.res).should.equal(true)
  138. })
  139. it('should set the correct content type on the request', function (ctx) {
  140. expect(ctx.res.contentType).toHaveBeenCalledWith('application/zip')
  141. })
  142. it('should flush the project to mongo', function (ctx) {
  143. return ctx.DocumentUpdaterHandler.flushProjectToMongo
  144. .calledWith(ctx.project_id)
  145. .should.equal(true)
  146. })
  147. it("should look up the project's name", function (ctx) {
  148. return ctx.ProjectGetter.getProject
  149. .calledWith(ctx.project_id, { name: true, 'overleaf.history.id': true })
  150. .should.equal(true)
  151. })
  152. it('should name the downloaded file after the project but sanitise special characters', function (ctx) {
  153. ctx.res.headers.should.deep.equal({
  154. 'Content-Disposition': `attachment; filename="project_name_with_accênts_and___special_characters.zip"`,
  155. 'Content-Type': 'application/zip',
  156. 'X-Accel-Buffering': 'no',
  157. 'X-Content-Type-Options': 'nosniff',
  158. })
  159. })
  160. it('should record the action via Metrics', function (ctx) {
  161. return ctx.Metrics.inc.calledWith('zip-downloads').should.equal(true)
  162. })
  163. it('should add an audit log entry', function (ctx) {
  164. return ctx.ProjectAuditLogHandler.addEntryInBackground
  165. .calledWith(
  166. ctx.project_id,
  167. 'project-downloaded',
  168. ctx.req.session.user._id,
  169. ctx.req.ip
  170. )
  171. .should.equal(true)
  172. })
  173. })
  174. describe('downloadMultipleProjects', function () {
  175. beforeEach(function (ctx) {
  176. ctx.stream = { pipe: sinon.stub() }
  177. ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects = sinon
  178. .stub()
  179. .yields(null, ctx.stream)
  180. ctx.project_ids = ['project-1', 'project-2']
  181. ctx.req.query = { project_ids: ctx.project_ids.join(',') }
  182. ctx.req.ip = '192.168.1.1'
  183. ctx.req.session = {
  184. user: {
  185. _id: 'user-id-123',
  186. email: 'user@example.com',
  187. },
  188. }
  189. ctx.DocumentUpdaterHandler.flushMultipleProjectsToMongo = sinon
  190. .stub()
  191. .callsArgWith(1)
  192. ctx.Metrics.inc = sinon.stub()
  193. return ctx.ProjectDownloadsController.downloadMultipleProjects(
  194. ctx.req,
  195. ctx.res,
  196. ctx.next
  197. )
  198. })
  199. it('should create a zip from the project', function (ctx) {
  200. return ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects
  201. .calledWith(ctx.project_ids)
  202. .should.equal(true)
  203. })
  204. it('should stream the zip to the request', function (ctx) {
  205. return ctx.stream.pipe.calledWith(ctx.res).should.equal(true)
  206. })
  207. it('should set the correct content type on the request', function (ctx) {
  208. expect(ctx.res.contentType).toHaveBeenCalledWith('application/zip')
  209. })
  210. it('should flush the projects to mongo', function (ctx) {
  211. return ctx.DocumentUpdaterHandler.flushMultipleProjectsToMongo
  212. .calledWith(ctx.project_ids)
  213. .should.equal(true)
  214. })
  215. it('should name the downloaded file after the project', function (ctx) {
  216. ctx.res.headers.should.deep.equal({
  217. 'Content-Disposition':
  218. 'attachment; filename="Overleaf Projects (2 items).zip"',
  219. 'Content-Type': 'application/zip',
  220. 'X-Accel-Buffering': 'no',
  221. 'X-Content-Type-Options': 'nosniff',
  222. })
  223. })
  224. it('should record the action via Metrics', function (ctx) {
  225. return ctx.Metrics.inc
  226. .calledWith('zip-downloads-multiple')
  227. .should.equal(true)
  228. })
  229. it('should add an audit log entry for each project', function (ctx) {
  230. ctx.ProjectAuditLogHandler.addEntryInBackground.callCount.should.equal(
  231. ctx.project_ids.length
  232. )
  233. for (const projectId of ctx.project_ids) {
  234. ctx.ProjectAuditLogHandler.addEntryInBackground
  235. .calledWith(
  236. projectId,
  237. 'project-downloaded',
  238. ctx.req.session.user._id,
  239. ctx.req.ip
  240. )
  241. .should.equal(true)
  242. }
  243. })
  244. })
  245. describe('exportProjectConversion', function () {
  246. describe('with a supported type (default streaming)', function () {
  247. beforeEach(async function (ctx) {
  248. ctx.projectId = '5e9b1c2a3b4c5d6e7f8a9b0c'
  249. ctx.userId = 'test-user-id'
  250. ctx.projectName = 'My Test Project'
  251. ctx.exportStream = { pipe: sinon.stub() }
  252. ctx.contentLength = 9876
  253. ctx.conversionIds = {
  254. conversionId: '12345678-1234-4234-8234-123456789012',
  255. buildId: '0123456789a-0123456789abcdef',
  256. clsiServerId: 'clsi-server-1',
  257. file: 'output.docx',
  258. }
  259. ctx.req.params = { Project_id: ctx.projectId, type: 'docx' }
  260. ctx.req.session = { user: { _id: ctx.userId } }
  261. ctx.req.query = {}
  262. ctx.req.ip = '192.168.1.1'
  263. ctx.res.attachment = sinon.stub().returns(ctx.res)
  264. ctx.SessionManager.getLoggedInUserId.returns(ctx.userId)
  265. ctx.ProjectGetter.promises.getProject.resolves({
  266. name: ctx.projectName,
  267. })
  268. ctx.DocumentConversionManager.promises.convertProjectToDocument.resolves(
  269. ctx.conversionIds
  270. )
  271. ctx.DocumentConversionManager.promises.streamConvertedProjectDocument.resolves(
  272. {
  273. stream: ctx.exportStream,
  274. contentLength: ctx.contentLength,
  275. }
  276. )
  277. await ctx.ProjectDownloadsController.exportProjectConversion(
  278. ctx.req,
  279. ctx.res,
  280. ctx.next
  281. )
  282. })
  283. it('should call convertProjectToDocument with the docx type', function (ctx) {
  284. sinon.assert.calledWith(
  285. ctx.DocumentConversionManager.promises.convertProjectToDocument,
  286. ctx.projectId,
  287. ctx.userId,
  288. 'docx'
  289. )
  290. })
  291. it('should fetch the prepared document via streamConvertedProjectDocument', function (ctx) {
  292. sinon.assert.calledWith(
  293. ctx.DocumentConversionManager.promises.streamConvertedProjectDocument,
  294. ctx.conversionIds
  295. )
  296. })
  297. it('should set the Content-Length header', function (ctx) {
  298. expect(ctx.res.headers['Content-Length']).to.equal(ctx.contentLength)
  299. })
  300. it('should set the attachment filename with safe project name', function (ctx) {
  301. sinon.assert.calledWith(ctx.res.attachment, 'My_Test_Project.docx')
  302. })
  303. it('should set the X-Content-Type-Options header', function (ctx) {
  304. expect(ctx.res.headers['X-Content-Type-Options']).to.equal('nosniff')
  305. })
  306. it('should set the X-Accel-Buffering header', function (ctx) {
  307. expect(ctx.res.headers['X-Accel-Buffering']).to.equal('no')
  308. })
  309. it('should add an audit log entry', function (ctx) {
  310. sinon.assert.calledWith(
  311. ctx.ProjectAuditLogHandler.addEntryInBackground,
  312. ctx.projectId,
  313. 'project-exported-docx',
  314. ctx.userId,
  315. ctx.req.ip
  316. )
  317. })
  318. it('should record the action via Metrics', function (ctx) {
  319. ctx.Metrics.inc
  320. .calledWith('document-exports', 1, { type: 'docx' })
  321. .should.equal(true)
  322. })
  323. it('should stream the document to the response', function (ctx) {
  324. sinon.assert.calledWith(ctx.pipeline, ctx.exportStream, ctx.res)
  325. })
  326. it('should record a successful convert-format analytics event', function (ctx) {
  327. sinon.assert.calledWith(
  328. ctx.AnalyticsManager.recordEventForUserInBackground,
  329. ctx.userId,
  330. 'convert-format',
  331. {
  332. sourceFormat: 'latex',
  333. targetFormat: 'docx',
  334. status: 'success',
  335. operation: 'export',
  336. }
  337. )
  338. })
  339. })
  340. describe('with responseFormat=json', function () {
  341. beforeEach(async function (ctx) {
  342. ctx.projectId = '5e9b1c2a3b4c5d6e7f8a9b0c'
  343. ctx.userId = 'test-user-id'
  344. ctx.req.params = { Project_id: ctx.projectId, type: 'docx' }
  345. ctx.req.query = { responseFormat: 'json' }
  346. ctx.req.session = { user: { _id: ctx.userId } }
  347. ctx.req.ip = '192.168.1.1'
  348. ctx.res.json = sinon.stub().returns(ctx.res)
  349. ctx.SessionManager.getLoggedInUserId.returns(ctx.userId)
  350. ctx.DocumentConversionManager.promises.convertProjectToDocument.resolves(
  351. {
  352. conversionId: '12345678-1234-4234-8234-123456789012',
  353. buildId: '0123456789a-0123456789abcdef',
  354. clsiServerId: 'clsi-server-1',
  355. file: 'output.docx',
  356. }
  357. )
  358. await ctx.ProjectDownloadsController.exportProjectConversion(
  359. ctx.req,
  360. ctx.res,
  361. ctx.next
  362. )
  363. })
  364. it('should call convertProjectToDocument', function (ctx) {
  365. sinon.assert.calledWith(
  366. ctx.DocumentConversionManager.promises.convertProjectToDocument,
  367. ctx.projectId,
  368. ctx.userId,
  369. 'docx'
  370. )
  371. })
  372. it('should not stream a document', function (ctx) {
  373. sinon.assert.notCalled(
  374. ctx.DocumentConversionManager.promises.streamConvertedProjectDocument
  375. )
  376. sinon.assert.notCalled(ctx.pipeline)
  377. })
  378. it('should add an audit log entry', function (ctx) {
  379. sinon.assert.calledWith(
  380. ctx.ProjectAuditLogHandler.addEntryInBackground,
  381. ctx.projectId,
  382. 'project-exported-docx',
  383. ctx.userId,
  384. ctx.req.ip
  385. )
  386. })
  387. it('should respond with a download URL pointing at the prepared output route', function (ctx) {
  388. sinon.assert.calledOnce(ctx.res.json)
  389. const arg = ctx.res.json.firstCall.args[0]
  390. expect(arg.downloadUrl).to.equal(
  391. '/project/5e9b1c2a3b4c5d6e7f8a9b0c/download/conversion/12345678-1234-4234-8234-123456789012/docx/build/0123456789a-0123456789abcdef/output/output.docx?clsiserverid=clsi-server-1'
  392. )
  393. })
  394. it('should omit the clsiserverid param when no clsiServerId is returned', async function (ctx) {
  395. ctx.res.json.resetHistory()
  396. ctx.DocumentConversionManager.promises.convertProjectToDocument.resolves(
  397. {
  398. conversionId: '12345678-1234-4234-8234-123456789012',
  399. buildId: '0123456789a-0123456789abcdef',
  400. clsiServerId: null,
  401. file: 'output.docx',
  402. }
  403. )
  404. await ctx.ProjectDownloadsController.exportProjectConversion(
  405. ctx.req,
  406. ctx.res,
  407. ctx.next
  408. )
  409. const arg = ctx.res.json.firstCall.args[0]
  410. const url = new URL(arg.downloadUrl, 'http://localhost')
  411. expect(url.searchParams.has('clsiserverid')).to.equal(false)
  412. })
  413. })
  414. describe('with type=markdown', function () {
  415. beforeEach(async function (ctx) {
  416. ctx.projectId = '5e9b1c2a3b4c5d6e7f8a9b0c'
  417. ctx.userId = 'test-user-id'
  418. ctx.projectName = 'My Test Project'
  419. ctx.exportStream = { pipe: sinon.stub() }
  420. ctx.contentLength = 9876
  421. ctx.req.params = { Project_id: ctx.projectId, type: 'markdown' }
  422. ctx.req.session = { user: { _id: ctx.userId } }
  423. ctx.req.query = {}
  424. ctx.req.ip = '192.168.1.1'
  425. ctx.res.attachment = sinon.stub().returns(ctx.res)
  426. ctx.SessionManager.getLoggedInUserId.returns(ctx.userId)
  427. ctx.ProjectGetter.promises.getProject.resolves({
  428. name: ctx.projectName,
  429. })
  430. ctx.DocumentConversionManager.promises.convertProjectToDocument.resolves(
  431. {
  432. conversionId: '12345678-1234-4234-8234-123456789012',
  433. buildId: '0123456789a-0123456789abcdef',
  434. clsiServerId: 'clsi-server-1',
  435. file: 'output.zip',
  436. }
  437. )
  438. ctx.DocumentConversionManager.promises.streamConvertedProjectDocument.resolves(
  439. {
  440. stream: ctx.exportStream,
  441. contentLength: ctx.contentLength,
  442. }
  443. )
  444. await ctx.ProjectDownloadsController.exportProjectConversion(
  445. ctx.req,
  446. ctx.res,
  447. ctx.next
  448. )
  449. })
  450. it('should call convertProjectToDocument with the markdown type', function (ctx) {
  451. sinon.assert.calledWith(
  452. ctx.DocumentConversionManager.promises.convertProjectToDocument,
  453. ctx.projectId,
  454. ctx.userId,
  455. 'markdown'
  456. )
  457. })
  458. it('should set the attachment filename with .zip extension', function (ctx) {
  459. sinon.assert.calledWith(ctx.res.attachment, 'My_Test_Project.zip')
  460. })
  461. it('should add an audit log entry for markdown export', function (ctx) {
  462. sinon.assert.calledWith(
  463. ctx.ProjectAuditLogHandler.addEntryInBackground,
  464. ctx.projectId,
  465. 'project-exported-markdown',
  466. ctx.userId,
  467. ctx.req.ip
  468. )
  469. })
  470. it('should record the action via Metrics with markdown type', function (ctx) {
  471. ctx.Metrics.inc
  472. .calledWith('document-exports', 1, { type: 'markdown' })
  473. .should.equal(true)
  474. })
  475. it('should stream the document to the response', function (ctx) {
  476. sinon.assert.calledWith(ctx.pipeline, ctx.exportStream, ctx.res)
  477. })
  478. })
  479. describe('with type=html', function () {
  480. beforeEach(async function (ctx) {
  481. ctx.projectId = '5e9b1c2a3b4c5d6e7f8a9b0c'
  482. ctx.userId = 'test-user-id'
  483. ctx.projectName = 'My Test Project'
  484. ctx.exportStream = { pipe: sinon.stub() }
  485. ctx.contentLength = 9876
  486. ctx.req.params = { Project_id: ctx.projectId, type: 'html' }
  487. ctx.req.session = { user: { _id: ctx.userId } }
  488. ctx.req.query = {}
  489. ctx.req.ip = '192.168.1.1'
  490. ctx.res.attachment = sinon.stub().returns(ctx.res)
  491. ctx.SessionManager.getLoggedInUserId.returns(ctx.userId)
  492. ctx.ProjectGetter.promises.getProject.resolves({
  493. name: ctx.projectName,
  494. })
  495. ctx.DocumentConversionManager.promises.convertProjectToDocument.resolves(
  496. {
  497. conversionId: '12345678-1234-4234-8234-123456789012',
  498. buildId: '0123456789a-0123456789abcdef',
  499. clsiServerId: 'clsi-server-1',
  500. file: 'output.zip',
  501. }
  502. )
  503. ctx.DocumentConversionManager.promises.streamConvertedProjectDocument.resolves(
  504. {
  505. stream: ctx.exportStream,
  506. contentLength: ctx.contentLength,
  507. }
  508. )
  509. await ctx.ProjectDownloadsController.exportProjectConversion(
  510. ctx.req,
  511. ctx.res,
  512. ctx.next
  513. )
  514. })
  515. it('should call convertProjectToDocument with the html type', function (ctx) {
  516. sinon.assert.calledWith(
  517. ctx.DocumentConversionManager.promises.convertProjectToDocument,
  518. ctx.projectId,
  519. ctx.userId,
  520. 'html'
  521. )
  522. })
  523. it('should set the attachment filename with .zip extension', function (ctx) {
  524. sinon.assert.calledWith(ctx.res.attachment, 'My_Test_Project.zip')
  525. })
  526. it('should add an audit log entry for html export', function (ctx) {
  527. sinon.assert.calledWith(
  528. ctx.ProjectAuditLogHandler.addEntryInBackground,
  529. ctx.projectId,
  530. 'project-exported-html',
  531. ctx.userId,
  532. ctx.req.ip
  533. )
  534. })
  535. it('should record the action via Metrics with html type', function (ctx) {
  536. ctx.Metrics.inc
  537. .calledWith('document-exports', 1, { type: 'html' })
  538. .should.equal(true)
  539. })
  540. it('should stream the document to the response', function (ctx) {
  541. sinon.assert.calledWith(ctx.pipeline, ctx.exportStream, ctx.res)
  542. })
  543. })
  544. describe('when conversion fails with a DocumentConversionError', function () {
  545. beforeEach(async function (ctx) {
  546. ctx.projectId = '5e9b1c2a3b4c5d6e7f8a9b0c'
  547. ctx.userId = 'test-user-id'
  548. ctx.req.params = { Project_id: ctx.projectId, type: 'docx' }
  549. ctx.req.query = {}
  550. ctx.req.session = { user: { _id: ctx.userId } }
  551. ctx.res.json = sinon.stub().returns(ctx.res)
  552. ctx.SessionManager.getLoggedInUserId.returns(ctx.userId)
  553. ctx.DocumentConversionManager.promises.convertProjectToDocument.rejects(
  554. new DocumentConversionError('parse error at line 5')
  555. )
  556. await ctx.ProjectDownloadsController.exportProjectConversion(
  557. ctx.req,
  558. ctx.res,
  559. ctx.next
  560. )
  561. })
  562. it('should respond with 422 and the pandoc message', function (ctx) {
  563. expect(ctx.res.statusCode).to.equal(422)
  564. sinon.assert.calledWith(ctx.res.json, {
  565. error: 'parse error at line 5',
  566. })
  567. })
  568. it('should not call next', function (ctx) {
  569. sinon.assert.notCalled(ctx.next)
  570. })
  571. it('should not attempt to stream a document', function (ctx) {
  572. sinon.assert.notCalled(
  573. ctx.DocumentConversionManager.promises.streamConvertedProjectDocument
  574. )
  575. })
  576. })
  577. })
  578. describe('downloadPreparedProjectExport', function () {
  579. describe('with a supported type', function () {
  580. beforeEach(async function (ctx) {
  581. ctx.projectId = '5e9b1c2a3b4c5d6e7f8a9b0c'
  582. ctx.projectName = 'My Test Project'
  583. ctx.exportStream = { pipe: sinon.stub() }
  584. ctx.contentLength = 9876
  585. ctx.req.params = {
  586. Project_id: ctx.projectId,
  587. type: 'docx',
  588. conversionId: '12345678-1234-4234-8234-123456789012',
  589. buildId: '0123456789a-0123456789abcdef',
  590. file: 'output.docx',
  591. }
  592. ctx.req.query = {
  593. clsiserverid: 'clsi-server-1',
  594. }
  595. ctx.res.attachment = sinon.stub().returns(ctx.res)
  596. ctx.ProjectGetter.promises.getProject.resolves({
  597. name: ctx.projectName,
  598. })
  599. ctx.DocumentConversionManager.promises.streamConvertedProjectDocument.resolves(
  600. {
  601. stream: ctx.exportStream,
  602. contentLength: ctx.contentLength,
  603. }
  604. )
  605. await ctx.ProjectDownloadsController.downloadPreparedProjectExport(
  606. ctx.req,
  607. ctx.res,
  608. ctx.next
  609. )
  610. })
  611. it('should call streamConvertedProjectDocument with the query params', function (ctx) {
  612. sinon.assert.calledWith(
  613. ctx.DocumentConversionManager.promises.streamConvertedProjectDocument,
  614. {
  615. conversionId: '12345678-1234-4234-8234-123456789012',
  616. buildId: '0123456789a-0123456789abcdef',
  617. clsiServerId: 'clsi-server-1',
  618. file: 'output.docx',
  619. }
  620. )
  621. })
  622. it('should set the attachment filename with safe project name', function (ctx) {
  623. sinon.assert.calledWith(ctx.res.attachment, 'My_Test_Project.docx')
  624. })
  625. it('should set the Content-Length header', function (ctx) {
  626. expect(ctx.res.headers['Content-Length']).to.equal(ctx.contentLength)
  627. })
  628. it('should stream the document to the response', function (ctx) {
  629. sinon.assert.calledWith(ctx.pipeline, ctx.exportStream, ctx.res)
  630. })
  631. it('should not log an audit entry', function (ctx) {
  632. sinon.assert.notCalled(ctx.ProjectAuditLogHandler.addEntryInBackground)
  633. })
  634. })
  635. })
  636. })