ConversionController.test.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  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. convertDocxToLaTeXWithLock: 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('convertDocxToLaTeX', 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. }
  82. ctx.res.sendStatus = sinon.stub()
  83. await ctx.ConversionController.convertDocxToLaTeX(ctx.req, ctx.res)
  84. })
  85. it('should remove the uploaded file', function (ctx) {
  86. sinon.assert.calledWith(ctx.fs.unlink, ctx.req.file.path)
  87. })
  88. it('should return 404', function (ctx) {
  89. sinon.assert.calledWith(ctx.res.sendStatus, 404)
  90. })
  91. it('should not call the conversion manager', function (ctx) {
  92. sinon.assert.notCalled(
  93. ctx.ConversionManager.promises.convertDocxToLaTeXWithLock
  94. )
  95. })
  96. })
  97. describe('successfully', function () {
  98. beforeEach(async function (ctx) {
  99. ctx.req = {
  100. file: { path: '/path/to/uploaded/file.docx' },
  101. }
  102. await ctx.ConversionController.convertDocxToLaTeX(ctx.req, ctx.res)
  103. })
  104. it('should call the conversion manager with the uploaded file path', function (ctx) {
  105. sinon.assert.calledWith(
  106. ctx.ConversionManager.promises.convertDocxToLaTeXWithLock,
  107. sinon.match(
  108. /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
  109. ),
  110. ctx.req.file.path
  111. )
  112. })
  113. it('should look up the generated zip file size', function (ctx) {
  114. sinon.assert.calledWith(ctx.fs.stat, ctx.zipPath)
  115. })
  116. it('should set the response headers for a zip file download', function (ctx) {
  117. sinon.assert.calledWith(
  118. ctx.res.setHeader,
  119. 'Content-Length',
  120. ctx.zipStat.size
  121. )
  122. sinon.assert.calledWith(ctx.res.attachment, 'conversion.zip')
  123. sinon.assert.calledWith(
  124. ctx.res.setHeader,
  125. 'X-Content-Type-Options',
  126. 'nosniff'
  127. )
  128. })
  129. it('should stream the generated zip file to the response', function (ctx) {
  130. sinon.assert.calledWith(ctx.fsSync.createReadStream, ctx.zipPath)
  131. sinon.assert.calledWith(ctx.pipeline, ctx.readStream, ctx.res)
  132. })
  133. it('should clean up the generated zip file', function (ctx) {
  134. sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir)
  135. })
  136. })
  137. describe('unsuccessfully', function () {
  138. describe('on streaming error', function () {
  139. it('should propagate the error and still clean up', async function (ctx) {
  140. ctx.pipeline.rejects(new Error('mock stream error'))
  141. const res = new PassThrough()
  142. res.attachment = sinon.stub()
  143. res.setHeader = sinon.stub()
  144. const req = { file: { path: '/path/to/uploaded/file.docx' } }
  145. await expect(
  146. ctx.ConversionController.convertDocxToLaTeX(req, res)
  147. ).to.be.rejectedWith('mock stream error')
  148. sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir)
  149. })
  150. })
  151. })
  152. })
  153. describe('convertProjectToDocument', function () {
  154. beforeEach(function (ctx) {
  155. ctx.req = {
  156. body: {},
  157. params: { project_id: 'test-project-id', user_id: 'test-user-id' },
  158. query: { type: 'docx' },
  159. }
  160. ctx.fs.stat.resolves(ctx.documentStat)
  161. })
  162. describe('when conversions are disabled', function () {
  163. beforeEach(async function (ctx) {
  164. ctx.Settings.enablePandocConversions = false
  165. ctx.res.sendStatus = sinon.stub()
  166. await ctx.ConversionController.convertProjectToDocument(
  167. ctx.req,
  168. ctx.res,
  169. sinon.stub()
  170. )
  171. })
  172. it('should return 404', function (ctx) {
  173. sinon.assert.calledWith(ctx.res.sendStatus, 404)
  174. })
  175. it('should not sync resources or call the conversion manager', function (ctx) {
  176. sinon.assert.notCalled(ctx.ResourceWriter.promises.syncResourcesToDisk)
  177. sinon.assert.notCalled(
  178. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock
  179. )
  180. })
  181. })
  182. describe('when an unsupported type is requested', function () {
  183. beforeEach(async function (ctx) {
  184. ctx.req.query = { type: 'unsupported' }
  185. ctx.res.sendStatus = sinon.stub()
  186. await ctx.ConversionController.convertProjectToDocument(
  187. ctx.req,
  188. ctx.res,
  189. sinon.stub()
  190. )
  191. })
  192. it('should return 400', function (ctx) {
  193. sinon.assert.calledWith(ctx.res.sendStatus, 400)
  194. })
  195. it('should not sync resources or call the conversion manager', function (ctx) {
  196. sinon.assert.notCalled(ctx.ResourceWriter.promises.syncResourcesToDisk)
  197. sinon.assert.notCalled(
  198. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock
  199. )
  200. })
  201. })
  202. const uuidDirPattern =
  203. /^\/compiles\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
  204. describe('successfully', function () {
  205. beforeEach(async function (ctx) {
  206. await ctx.ConversionController.convertProjectToDocument(
  207. ctx.req,
  208. ctx.res,
  209. sinon.stub()
  210. )
  211. })
  212. it('should sync resources to a unique conversion directory', function (ctx) {
  213. sinon.assert.calledWith(
  214. ctx.ResourceWriter.promises.syncResourcesToDisk,
  215. sinon.match({ rootResourcePath: 'main.tex' }),
  216. sinon.match(uuidDirPattern)
  217. )
  218. })
  219. it('should call convertLaTeXToDocumentInDirWithLock with docx type and extension', function (ctx) {
  220. sinon.assert.calledWith(
  221. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock,
  222. sinon.match(
  223. /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
  224. ),
  225. sinon.match(uuidDirPattern),
  226. 'main.tex',
  227. 'docx',
  228. 'docx'
  229. )
  230. })
  231. it('should set the Content-Length header from the document stat', function (ctx) {
  232. sinon.assert.calledWith(
  233. ctx.res.setHeader,
  234. 'Content-Length',
  235. ctx.documentStat.size
  236. )
  237. })
  238. it('should set the attachment filename', function (ctx) {
  239. sinon.assert.calledWith(ctx.res.attachment, 'output.docx')
  240. })
  241. it('should set X-Content-Type-Options header', function (ctx) {
  242. sinon.assert.calledWith(
  243. ctx.res.setHeader,
  244. 'X-Content-Type-Options',
  245. 'nosniff'
  246. )
  247. })
  248. it('should stream the document to the response', function (ctx) {
  249. sinon.assert.calledWith(ctx.fsSync.createReadStream, ctx.documentPath)
  250. sinon.assert.calledWith(ctx.pipeline, ctx.readStream, ctx.res)
  251. })
  252. it('should clean up the conversion directory', function (ctx) {
  253. sinon.assert.calledWith(ctx.fs.rm, sinon.match(uuidDirPattern), {
  254. recursive: true,
  255. force: true,
  256. })
  257. })
  258. })
  259. describe('when conversion fails', function () {
  260. beforeEach(async function (ctx) {
  261. ctx.next = sinon.stub()
  262. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock.rejects(
  263. new Error('mock conversion error')
  264. )
  265. await ctx.ConversionController.convertProjectToDocument(
  266. ctx.req,
  267. ctx.res,
  268. ctx.next
  269. )
  270. })
  271. it('should pass the error to next', function (ctx) {
  272. sinon.assert.calledOnce(ctx.next)
  273. expect(ctx.next.firstCall.args[0]).to.be.instanceOf(Error)
  274. })
  275. it('should still clean up the conversion directory', function (ctx) {
  276. sinon.assert.calledWith(ctx.fs.rm, sinon.match(uuidDirPattern), {
  277. recursive: true,
  278. force: true,
  279. })
  280. })
  281. })
  282. })
  283. })