ConversionController.test.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. import sinon from 'sinon'
  2. import { vi, describe, it, beforeEach, expect } from 'vitest'
  3. import Path from 'node:path'
  4. import { PassThrough } from 'node:stream'
  5. const MODULE_PATH = Path.join(
  6. import.meta.dirname,
  7. '../../../app/js/ConversionController'
  8. )
  9. describe('ConversionController', function () {
  10. beforeEach(async function (ctx) {
  11. ctx.conversionDir = '/path/to/conversion/result'
  12. ctx.zipPath = '/path/to/conversion/result/output.zip'
  13. ctx.zipStat = { size: 1234 }
  14. ctx.documentPath = '/compiles/output-uuid/output-uuid.docx'
  15. ctx.documentStat = { size: 5678 }
  16. ctx.Settings = {
  17. enablePandocConversions: true,
  18. path: { compilesDir: '/compiles' },
  19. }
  20. ctx.parsedRequest = { rootResourcePath: 'main.tex' }
  21. ctx.ConversionManager = {
  22. promises: {
  23. convertToLaTeXWithLock: sinon.stub().resolves(ctx.zipPath),
  24. convertLaTeXToDocumentInDirWithLock: sinon
  25. .stub()
  26. .resolves(ctx.documentPath),
  27. },
  28. }
  29. ctx.ResourceWriter = {
  30. promises: {
  31. syncResourcesToDisk: sinon.stub().resolves(),
  32. },
  33. }
  34. ctx.RequestParser = {
  35. promises: {
  36. parse: sinon.stub().resolves(ctx.parsedRequest),
  37. },
  38. }
  39. ctx.fs = {
  40. stat: sinon.stub().resolves(ctx.zipStat),
  41. unlink: sinon.stub().resolves(),
  42. rm: sinon.stub().resolves(),
  43. }
  44. ctx.readStream = new PassThrough()
  45. ctx.fsSync = {
  46. createReadStream: sinon.stub().returns(ctx.readStream),
  47. }
  48. ctx.pipeline = sinon.stub().resolves()
  49. vi.doMock('node:fs/promises', () => ({
  50. default: ctx.fs,
  51. }))
  52. vi.doMock('node:fs', () => ({
  53. default: ctx.fsSync,
  54. }))
  55. vi.doMock('node:stream/promises', () => ({
  56. pipeline: ctx.pipeline,
  57. }))
  58. vi.doMock('@overleaf/settings', () => ({
  59. default: ctx.Settings,
  60. }))
  61. vi.doMock('../../../app/js/ConversionManager', () => ({
  62. default: ctx.ConversionManager,
  63. }))
  64. vi.doMock('../../../app/js/ResourceWriter', () => ({
  65. default: ctx.ResourceWriter,
  66. }))
  67. vi.doMock('../../../app/js/RequestParser', () => ({
  68. default: ctx.RequestParser,
  69. }))
  70. ctx.res = new PassThrough()
  71. ctx.res.attachment = sinon.stub()
  72. ctx.res.setHeader = sinon.stub()
  73. ctx.ConversionController = (await import(MODULE_PATH)).default
  74. })
  75. describe('convertDocumentToLaTeX', function () {
  76. describe('when conversions are disabled', function () {
  77. beforeEach(async function (ctx) {
  78. ctx.Settings.enablePandocConversions = false
  79. ctx.req = {
  80. file: { path: '/path/to/uploaded/file.docx' },
  81. query: { type: 'docx' },
  82. }
  83. ctx.res.sendStatus = sinon.stub()
  84. await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
  85. })
  86. it('should remove the uploaded file', function (ctx) {
  87. sinon.assert.calledWith(ctx.fs.unlink, ctx.req.file.path)
  88. })
  89. it('should return 404', function (ctx) {
  90. sinon.assert.calledWith(ctx.res.sendStatus, 404)
  91. })
  92. it('should not call the conversion manager', function (ctx) {
  93. sinon.assert.notCalled(
  94. ctx.ConversionManager.promises.convertToLaTeXWithLock
  95. )
  96. })
  97. })
  98. describe('when conversionType is missing', function () {
  99. beforeEach(async function (ctx) {
  100. ctx.req = {
  101. file: { path: '/path/to/uploaded/file.docx' },
  102. query: {},
  103. }
  104. ctx.res.sendStatus = sinon.stub()
  105. await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
  106. })
  107. it('should remove the uploaded file', function (ctx) {
  108. sinon.assert.calledWith(ctx.fs.unlink, ctx.req.file.path)
  109. })
  110. it('should return 400', function (ctx) {
  111. sinon.assert.calledWith(ctx.res.sendStatus, 400)
  112. })
  113. it('should not call the conversion manager', function (ctx) {
  114. sinon.assert.notCalled(
  115. ctx.ConversionManager.promises.convertToLaTeXWithLock
  116. )
  117. })
  118. })
  119. describe('when conversionType is unsupported', function () {
  120. beforeEach(async function (ctx) {
  121. ctx.req = {
  122. file: { path: '/path/to/uploaded/file.docx' },
  123. query: { type: 'invalid' },
  124. }
  125. ctx.res.sendStatus = sinon.stub()
  126. await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
  127. })
  128. it('should remove the uploaded file', function (ctx) {
  129. sinon.assert.calledWith(ctx.fs.unlink, ctx.req.file.path)
  130. })
  131. it('should return 400', function (ctx) {
  132. sinon.assert.calledWith(ctx.res.sendStatus, 400)
  133. })
  134. it('should not call the conversion manager', function (ctx) {
  135. sinon.assert.notCalled(
  136. ctx.ConversionManager.promises.convertToLaTeXWithLock
  137. )
  138. })
  139. })
  140. describe('successfully', function () {
  141. beforeEach(async function (ctx) {
  142. ctx.req = {
  143. file: { path: '/path/to/uploaded/file.docx' },
  144. query: { type: 'docx' },
  145. }
  146. await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
  147. })
  148. it('should call the conversion manager with the uploaded file path and type', function (ctx) {
  149. sinon.assert.calledWith(
  150. ctx.ConversionManager.promises.convertToLaTeXWithLock,
  151. sinon.match(
  152. /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
  153. ),
  154. ctx.req.file.path,
  155. 'docx'
  156. )
  157. })
  158. it('should look up the generated zip file size', function (ctx) {
  159. sinon.assert.calledWith(ctx.fs.stat, ctx.zipPath)
  160. })
  161. it('should set the response headers for a zip file download', function (ctx) {
  162. sinon.assert.calledWith(
  163. ctx.res.setHeader,
  164. 'Content-Length',
  165. ctx.zipStat.size
  166. )
  167. sinon.assert.calledWith(ctx.res.attachment, 'conversion.zip')
  168. sinon.assert.calledWith(
  169. ctx.res.setHeader,
  170. 'X-Content-Type-Options',
  171. 'nosniff'
  172. )
  173. })
  174. it('should stream the generated zip file to the response', function (ctx) {
  175. sinon.assert.calledWith(ctx.fsSync.createReadStream, ctx.zipPath)
  176. sinon.assert.calledWith(ctx.pipeline, ctx.readStream, ctx.res)
  177. })
  178. it('should clean up the generated zip file', function (ctx) {
  179. sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir)
  180. })
  181. })
  182. describe('with conversionType=markdown', function () {
  183. beforeEach(async function (ctx) {
  184. ctx.req = {
  185. file: { path: '/path/to/uploaded/file.md' },
  186. query: { type: 'markdown' },
  187. }
  188. await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
  189. })
  190. it('should call the conversion manager with the uploaded file path and markdown type', function (ctx) {
  191. sinon.assert.calledWith(
  192. ctx.ConversionManager.promises.convertToLaTeXWithLock,
  193. sinon.match(
  194. /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
  195. ),
  196. ctx.req.file.path,
  197. 'markdown'
  198. )
  199. })
  200. })
  201. describe('unsuccessfully', function () {
  202. describe('on streaming error', function () {
  203. it('should propagate the error and still clean up', async function (ctx) {
  204. ctx.pipeline.rejects(new Error('mock stream error'))
  205. const res = new PassThrough()
  206. res.attachment = sinon.stub()
  207. res.setHeader = sinon.stub()
  208. const req = {
  209. file: { path: '/path/to/uploaded/file.docx' },
  210. query: { type: 'docx' },
  211. }
  212. await expect(
  213. ctx.ConversionController.convertDocumentToLaTeX(req, res)
  214. ).to.be.rejectedWith('mock stream error')
  215. sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir)
  216. })
  217. })
  218. })
  219. })
  220. describe('convertProjectToDocument', function () {
  221. beforeEach(function (ctx) {
  222. ctx.req = {
  223. body: {},
  224. params: { project_id: 'test-project-id', user_id: 'test-user-id' },
  225. query: { type: 'docx' },
  226. }
  227. ctx.fs.stat.resolves(ctx.documentStat)
  228. })
  229. describe('when conversions are disabled', function () {
  230. beforeEach(async function (ctx) {
  231. ctx.Settings.enablePandocConversions = false
  232. ctx.res.sendStatus = sinon.stub()
  233. await ctx.ConversionController.convertProjectToDocument(
  234. ctx.req,
  235. ctx.res,
  236. sinon.stub()
  237. )
  238. })
  239. it('should return 404', function (ctx) {
  240. sinon.assert.calledWith(ctx.res.sendStatus, 404)
  241. })
  242. it('should not sync resources or call the conversion manager', function (ctx) {
  243. sinon.assert.notCalled(ctx.ResourceWriter.promises.syncResourcesToDisk)
  244. sinon.assert.notCalled(
  245. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock
  246. )
  247. })
  248. })
  249. describe('when an unsupported type is requested', function () {
  250. beforeEach(async function (ctx) {
  251. ctx.req.query = { type: 'unsupported' }
  252. ctx.res.sendStatus = sinon.stub()
  253. await ctx.ConversionController.convertProjectToDocument(
  254. ctx.req,
  255. ctx.res,
  256. sinon.stub()
  257. )
  258. })
  259. it('should return 400', function (ctx) {
  260. sinon.assert.calledWith(ctx.res.sendStatus, 400)
  261. })
  262. it('should not sync resources or call the conversion manager', function (ctx) {
  263. sinon.assert.notCalled(ctx.ResourceWriter.promises.syncResourcesToDisk)
  264. sinon.assert.notCalled(
  265. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock
  266. )
  267. })
  268. })
  269. const uuidDirPattern =
  270. /^\/compiles\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
  271. describe('successfully', function () {
  272. beforeEach(async function (ctx) {
  273. await ctx.ConversionController.convertProjectToDocument(
  274. ctx.req,
  275. ctx.res,
  276. sinon.stub()
  277. )
  278. })
  279. it('should sync resources to a unique conversion directory', function (ctx) {
  280. sinon.assert.calledWith(
  281. ctx.ResourceWriter.promises.syncResourcesToDisk,
  282. sinon.match({ rootResourcePath: 'main.tex' }),
  283. sinon.match(uuidDirPattern)
  284. )
  285. })
  286. it('should call convertLaTeXToDocumentInDirWithLock with docx type and extension', function (ctx) {
  287. sinon.assert.calledWith(
  288. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock,
  289. sinon.match(
  290. /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
  291. ),
  292. sinon.match(uuidDirPattern),
  293. 'main.tex',
  294. 'docx',
  295. 'docx'
  296. )
  297. })
  298. it('should set the Content-Length header from the document stat', function (ctx) {
  299. sinon.assert.calledWith(
  300. ctx.res.setHeader,
  301. 'Content-Length',
  302. ctx.documentStat.size
  303. )
  304. })
  305. it('should set the attachment filename', function (ctx) {
  306. sinon.assert.calledWith(ctx.res.attachment, 'output.docx')
  307. })
  308. it('should set X-Content-Type-Options header', function (ctx) {
  309. sinon.assert.calledWith(
  310. ctx.res.setHeader,
  311. 'X-Content-Type-Options',
  312. 'nosniff'
  313. )
  314. })
  315. it('should stream the document to the response', function (ctx) {
  316. sinon.assert.calledWith(ctx.fsSync.createReadStream, ctx.documentPath)
  317. sinon.assert.calledWith(ctx.pipeline, ctx.readStream, ctx.res)
  318. })
  319. it('should clean up the conversion directory', function (ctx) {
  320. sinon.assert.calledWith(ctx.fs.rm, sinon.match(uuidDirPattern), {
  321. recursive: true,
  322. force: true,
  323. })
  324. })
  325. })
  326. describe('when conversion fails', function () {
  327. beforeEach(async function (ctx) {
  328. ctx.next = sinon.stub()
  329. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock.rejects(
  330. new Error('mock conversion error')
  331. )
  332. await ctx.ConversionController.convertProjectToDocument(
  333. ctx.req,
  334. ctx.res,
  335. ctx.next
  336. )
  337. })
  338. it('should pass the error to next', function (ctx) {
  339. sinon.assert.calledOnce(ctx.next)
  340. expect(ctx.next.firstCall.args[0]).to.be.instanceOf(Error)
  341. })
  342. it('should still clean up the conversion directory', function (ctx) {
  343. sinon.assert.calledWith(ctx.fs.rm, sinon.match(uuidDirPattern), {
  344. recursive: true,
  345. force: true,
  346. })
  347. })
  348. })
  349. })
  350. })