ProjectUploadController.test.mjs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. // TODO: This file was created by bulk-decaffeinate.
  2. // Fix any style issues and re-enable lint.
  3. /*
  4. * decaffeinate suggestions:
  5. * DS206: Consider reworking classes to avoid initClass
  6. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  7. */
  8. import { expect, vi } from 'vitest'
  9. import sinon from 'sinon'
  10. import MockRequest from '../helpers/MockRequest.mjs'
  11. import MockResponse from '../helpers/MockResponse.mjs'
  12. import ArchiveErrors from '../../../../app/src/Features/Uploads/ArchiveErrors.mjs'
  13. import { FileTooLargeError } from '../../../../app/src/Features/Errors/Errors.js'
  14. const modulePath =
  15. '../../../../app/src/Features/Uploads/ProjectUploadController.mjs'
  16. describe('ProjectUploadController', function () {
  17. beforeEach(async function (ctx) {
  18. let Timer
  19. ctx.req = new MockRequest(vi)
  20. ctx.res = new MockResponse(vi)
  21. ctx.user_id = 'user-id-123'
  22. ctx.metrics = {
  23. Timer: (Timer = (function () {
  24. Timer = class Timer {
  25. static initClass() {
  26. this.prototype.done = sinon.stub()
  27. }
  28. }
  29. Timer.initClass()
  30. return Timer
  31. })()),
  32. }
  33. ctx.SessionManager = {
  34. getLoggedInUserId: sinon.stub().returns(ctx.user_id),
  35. }
  36. ctx.ProjectLocator = {
  37. promises: {},
  38. }
  39. ctx.EditorController = {
  40. promises: {},
  41. }
  42. ctx.ProjectOptionsHandler = {
  43. promises: {
  44. setCompiler: sinon.stub().resolves(),
  45. },
  46. }
  47. ctx.DocumentConversionManager = {
  48. promises: {
  49. convertDocumentToLaTeXZipArchive: sinon.stub(),
  50. },
  51. }
  52. vi.doMock('multer', () => ({
  53. default: sinon.stub(),
  54. }))
  55. vi.doMock('@overleaf/settings', () => ({
  56. default: { path: {} },
  57. }))
  58. vi.doMock(
  59. '../../../../app/src/Features/Uploads/ProjectUploadManager',
  60. () => ({
  61. default: (ctx.ProjectUploadManager = { promises: {} }),
  62. })
  63. )
  64. vi.doMock(
  65. '../../../../app/src/Features/Uploads/FileSystemImportManager',
  66. () => ({
  67. default: (ctx.FileSystemImportManager = {}),
  68. })
  69. )
  70. vi.doMock('@overleaf/metrics', () => ({
  71. default: ctx.metrics,
  72. }))
  73. vi.doMock(
  74. '../../../../app/src/Features/Authentication/SessionManager',
  75. () => ({
  76. default: ctx.SessionManager,
  77. })
  78. )
  79. vi.doMock(
  80. '../../../../app/src/Features/Uploads/ArchiveErrors',
  81. () => ArchiveErrors
  82. )
  83. vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
  84. default: ctx.ProjectLocator,
  85. }))
  86. vi.doMock('../../../../app/src/Features/Editor/EditorController', () => ({
  87. default: ctx.EditorController,
  88. }))
  89. vi.doMock(
  90. '../../../../app/src/Features/Project/ProjectOptionsHandler',
  91. () => ({
  92. default: ctx.ProjectOptionsHandler,
  93. })
  94. )
  95. vi.doMock(
  96. '../../../../app/src/Features/Uploads/DocumentConversionManager.mjs',
  97. () => ({
  98. default: ctx.DocumentConversionManager,
  99. })
  100. )
  101. vi.doMock('node:fs', () => ({
  102. default: (ctx.fs = {}),
  103. }))
  104. vi.doMock('node:fs/promises', () => ({
  105. default: (ctx.fsPromises = {}),
  106. }))
  107. ctx.ProjectUploadController = (await import(modulePath)).default
  108. })
  109. describe('uploadProject', function () {
  110. beforeEach(function (ctx) {
  111. ctx.path = '/path/to/file/on/disk.zip'
  112. ctx.fileName = 'filename.zip'
  113. ctx.req.file = {
  114. path: ctx.path,
  115. }
  116. ctx.req.body = {
  117. name: ctx.fileName,
  118. }
  119. ctx.req.session = {
  120. user: {
  121. _id: ctx.user_id,
  122. },
  123. }
  124. ctx.project = { _id: (ctx.project_id = 'project-id-123') }
  125. ctx.fs.unlink = sinon.stub()
  126. ctx.fsPromises.unlink = sinon.stub().resolves()
  127. })
  128. describe('successfully', function () {
  129. beforeEach(function (ctx) {
  130. ctx.ProjectUploadManager.createProjectFromZipArchive = sinon
  131. .stub()
  132. .callsArgWith(3, null, ctx.project)
  133. ctx.ProjectUploadController.uploadProject(ctx.req, ctx.res)
  134. })
  135. it('should create a project owned by the logged in user', function (ctx) {
  136. ctx.ProjectUploadManager.createProjectFromZipArchive
  137. .calledWith(ctx.user_id)
  138. .should.equal(true)
  139. })
  140. it('should create a project with the same name as the zip archive', function (ctx) {
  141. ctx.ProjectUploadManager.createProjectFromZipArchive
  142. .calledWith(sinon.match.any, 'filename', sinon.match.any)
  143. .should.equal(true)
  144. })
  145. it('should create a project from the zip archive', function (ctx) {
  146. ctx.ProjectUploadManager.createProjectFromZipArchive
  147. .calledWith(sinon.match.any, sinon.match.any, ctx.path)
  148. .should.equal(true)
  149. })
  150. it('should return a successful response to the FileUploader client', function (ctx) {
  151. expect(ctx.res.body).to.deep.equal(
  152. JSON.stringify({
  153. success: true,
  154. project_id: ctx.project_id,
  155. })
  156. )
  157. })
  158. it('should record the time taken to do the upload', function (ctx) {
  159. ctx.metrics.Timer.prototype.done.called.should.equal(true)
  160. })
  161. it('should remove the uploaded file', function (ctx) {
  162. ctx.fs.unlink.calledWith(ctx.path).should.equal(true)
  163. })
  164. })
  165. describe('when ProjectUploadManager.createProjectFromZipArchive fails', function () {
  166. beforeEach(function (ctx) {
  167. ctx.ProjectUploadManager.createProjectFromZipArchive = sinon
  168. .stub()
  169. .callsArgWith(3, new Error('Something went wrong'), ctx.project)
  170. ctx.ProjectUploadController.uploadProject(ctx.req, ctx.res)
  171. })
  172. it('should return a failed response to the FileUploader client', function (ctx) {
  173. expect(ctx.res.body).to.deep.equal(
  174. JSON.stringify({ success: false, error: 'upload_failed' })
  175. )
  176. })
  177. it('should remove the uploaded file', function (ctx) {
  178. ctx.fs.unlink.calledWith(ctx.path).should.equal(true)
  179. })
  180. })
  181. describe('when ProjectUploadManager.createProjectFromZipArchive reports the file as invalid', function () {
  182. beforeEach(function (ctx) {
  183. ctx.ProjectUploadManager.createProjectFromZipArchive = sinon
  184. .stub()
  185. .callsArgWith(
  186. 3,
  187. new ArchiveErrors.ZipContentsTooLargeError(),
  188. ctx.project
  189. )
  190. ctx.ProjectUploadController.uploadProject(ctx.req, ctx.res)
  191. })
  192. it('should return the reported error to the FileUploader client', function (ctx) {
  193. expect(JSON.parse(ctx.res.body)).to.deep.equal({
  194. success: false,
  195. error: 'zip_contents_too_large',
  196. })
  197. })
  198. it("should return an 'unprocessable entity' status code", function (ctx) {
  199. expect(ctx.res.statusCode).to.equal(422)
  200. })
  201. it('should remove the uploaded file', function (ctx) {
  202. ctx.fs.unlink.calledWith(ctx.path).should.equal(true)
  203. })
  204. })
  205. })
  206. describe('uploadFile', function () {
  207. beforeEach(function (ctx) {
  208. ctx.project_id = 'project-id-123'
  209. ctx.folder_id = 'folder-id-123'
  210. ctx.path = '/path/to/file/on/disk.png'
  211. ctx.fileName = 'filename.png'
  212. ctx.req.file = {
  213. path: ctx.path,
  214. }
  215. ctx.req.body = {
  216. name: ctx.fileName,
  217. }
  218. ctx.req.session = {
  219. user: {
  220. _id: ctx.user_id,
  221. },
  222. }
  223. ctx.req.params = { Project_id: ctx.project_id }
  224. ctx.req.query = { folder_id: ctx.folder_id }
  225. ctx.fs.unlink = sinon.stub()
  226. ctx.fsPromises.unlink = sinon.stub().resolves()
  227. })
  228. describe('successfully', function () {
  229. beforeEach(function (ctx) {
  230. ctx.entity = {
  231. _id: '1234',
  232. type: 'file',
  233. }
  234. ctx.FileSystemImportManager.addEntity = sinon
  235. .stub()
  236. .callsArgWith(6, null, ctx.entity)
  237. ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res)
  238. })
  239. it('should insert the file', function (ctx) {
  240. return ctx.FileSystemImportManager.addEntity
  241. .calledWith(
  242. ctx.user_id,
  243. ctx.project_id,
  244. ctx.folder_id,
  245. ctx.fileName,
  246. ctx.path
  247. )
  248. .should.equal(true)
  249. })
  250. it('should return a successful response to the FileUploader client', function (ctx) {
  251. expect(ctx.res.body).to.deep.equal(
  252. JSON.stringify({
  253. success: true,
  254. entity_id: ctx.entity._id,
  255. entity_type: 'file',
  256. })
  257. )
  258. })
  259. it('should time the request', function (ctx) {
  260. ctx.metrics.Timer.prototype.done.called.should.equal(true)
  261. })
  262. it('should remove the uploaded file', function (ctx) {
  263. ctx.fs.unlink.calledWith(ctx.path).should.equal(true)
  264. })
  265. })
  266. describe('with folder structure', function () {
  267. beforeEach(async function (ctx) {
  268. await new Promise(resolve => {
  269. ctx.entity = {
  270. _id: '1234',
  271. type: 'file',
  272. }
  273. ctx.FileSystemImportManager.addEntity = sinon
  274. .stub()
  275. .callsArgWith(6, null, ctx.entity)
  276. ctx.ProjectLocator.promises.findElement = sinon.stub().resolves({
  277. path: { fileSystem: '/test' },
  278. })
  279. ctx.EditorController.promises.mkdirp = sinon.stub().resolves({
  280. lastFolder: { _id: 'folder-id' },
  281. })
  282. ctx.req.body.relativePath = 'foo/bar/' + ctx.fileName
  283. ctx.res.json = data => {
  284. expect(data.success).to.be.true
  285. resolve()
  286. }
  287. ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res)
  288. })
  289. })
  290. it('should insert the file', function (ctx) {
  291. ctx.ProjectLocator.promises.findElement.should.be.calledOnceWithExactly(
  292. {
  293. project_id: ctx.project_id,
  294. element_id: ctx.folder_id,
  295. type: 'folder',
  296. }
  297. )
  298. ctx.EditorController.promises.mkdirp.should.be.calledWith(
  299. ctx.project_id,
  300. '/test/foo/bar',
  301. ctx.user_id
  302. )
  303. ctx.FileSystemImportManager.addEntity.should.be.calledOnceWith(
  304. ctx.user_id,
  305. ctx.project_id,
  306. 'folder-id',
  307. ctx.fileName,
  308. ctx.path
  309. )
  310. })
  311. })
  312. describe('when looking up the folder structure fails', function () {
  313. beforeEach(async function (ctx) {
  314. await new Promise(resolve => {
  315. ctx.error = new Error('woops')
  316. ctx.ProjectLocator.promises.findElement = sinon
  317. .stub()
  318. .rejects(ctx.error)
  319. ctx.req.body.relativePath = 'foo/bar/' + ctx.fileName
  320. ctx.next = error => {
  321. ctx.nextError = error
  322. resolve()
  323. }
  324. ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res, ctx.next)
  325. })
  326. })
  327. it('should unlink the file', function (ctx) {
  328. ctx.fsPromises.unlink.should.have.been.calledWith(ctx.path)
  329. })
  330. it('should call next with the error', function (ctx) {
  331. expect(ctx.nextError).to.equal(ctx.error)
  332. })
  333. })
  334. describe('when FileSystemImportManager.addEntity returns a generic error', function () {
  335. beforeEach(function (ctx) {
  336. ctx.FileSystemImportManager.addEntity = sinon
  337. .stub()
  338. .callsArgWith(6, new Error('Sorry something went wrong'))
  339. ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res)
  340. })
  341. it('should return an unsuccessful response to the FileUploader client', function (ctx) {
  342. expect(ctx.res.body).to.deep.equal(
  343. JSON.stringify({
  344. success: false,
  345. })
  346. )
  347. })
  348. it('should remove the uploaded file', function (ctx) {
  349. ctx.fs.unlink.calledWith(ctx.path).should.equal(true)
  350. })
  351. })
  352. describe('when FileSystemImportManager.addEntity returns a too many files error', function () {
  353. beforeEach(function (ctx) {
  354. ctx.FileSystemImportManager.addEntity = sinon
  355. .stub()
  356. .callsArgWith(6, new Error('project_has_too_many_files'))
  357. ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res)
  358. })
  359. it('should return an unsuccessful response to the FileUploader client', function (ctx) {
  360. expect(ctx.res.body).to.deep.equal(
  361. JSON.stringify({
  362. success: false,
  363. error: 'project_has_too_many_files',
  364. })
  365. )
  366. })
  367. it('should remove the uploaded file', function (ctx) {
  368. ctx.fs.unlink.calledWith(ctx.path).should.equal(true)
  369. })
  370. })
  371. describe('with an invalid filename', function () {
  372. beforeEach(function (ctx) {
  373. ctx.req.body.name = ''
  374. ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res)
  375. })
  376. it('should return a non success response', function (ctx) {
  377. expect(ctx.res.body).to.deep.equal(
  378. JSON.stringify({
  379. success: false,
  380. error: 'invalid_filename',
  381. })
  382. )
  383. })
  384. it('should remove the uploaded file', function (ctx) {
  385. ctx.fsPromises.unlink.calledWith(ctx.path).should.equal(true)
  386. })
  387. })
  388. describe('with a filename that is too long', function () {
  389. beforeEach(function (ctx) {
  390. ctx.req.body.name = 'a'.repeat(151)
  391. ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res)
  392. })
  393. it('should return a non success response', function (ctx) {
  394. expect(ctx.res.body).to.deep.equal(
  395. JSON.stringify({
  396. success: false,
  397. error: 'invalid_filename',
  398. })
  399. )
  400. })
  401. it('should remove the uploaded file', function (ctx) {
  402. ctx.fsPromises.unlink.calledWith(ctx.path).should.equal(true)
  403. })
  404. })
  405. })
  406. describe('importDocument', function () {
  407. beforeEach(async function (ctx) {
  408. ctx.req.file = {
  409. path: '/path/to/uploaded/file.docx',
  410. }
  411. ctx.req.body = {
  412. name: 'file.docx',
  413. }
  414. ctx.req.query = { type: 'docx' }
  415. ctx.archivePath = '/path/to/archive.zip'
  416. ctx.fsPromises.unlink = sinon.stub().resolves()
  417. })
  418. describe('with conversionType=docx', async function () {
  419. describe('successfully', async function () {
  420. beforeEach(async function (ctx) {
  421. ctx.DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive =
  422. sinon.stub().resolves(ctx.archivePath)
  423. ctx.ProjectUploadManager.promises.createProjectFromZipArchive = sinon
  424. .stub()
  425. .resolves({
  426. _id: 'new-project-id',
  427. })
  428. await new Promise(resolve => {
  429. ctx.res.json = data => {
  430. expect(data.success).to.be.true
  431. expect(data.project_id).to.equal('new-project-id')
  432. resolve()
  433. }
  434. ctx.ProjectUploadController.importDocument(ctx.req, ctx.res)
  435. })
  436. })
  437. it('should call the DocumentConversionManager with file path and type', function (ctx) {
  438. expect(
  439. ctx.DocumentConversionManager.promises
  440. .convertDocumentToLaTeXZipArchive
  441. ).to.have.been.calledWith(ctx.req.file.path, ctx.user_id, 'docx')
  442. })
  443. it('should use the resulting archive to create a new project', function (ctx) {
  444. expect(
  445. ctx.ProjectUploadManager.promises.createProjectFromZipArchive
  446. ).to.have.been.calledWith(ctx.user_id, 'file', ctx.archivePath)
  447. })
  448. it('should set the compiler to lualatex', function (ctx) {
  449. expect(
  450. ctx.ProjectOptionsHandler.promises.setCompiler
  451. ).to.have.been.calledWith('new-project-id', 'lualatex')
  452. })
  453. it('should unlink the archive after creating the project', function (ctx) {
  454. expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.archivePath)
  455. })
  456. it('should unlink the uploaded file', function (ctx) {
  457. expect(ctx.fsPromises.unlink).to.have.been.calledWith(
  458. ctx.req.file.path
  459. )
  460. })
  461. })
  462. })
  463. describe('with conversionType=markdown', async function () {
  464. beforeEach(async function (ctx) {
  465. ctx.req.file = {
  466. path: '/path/to/uploaded/file.md',
  467. }
  468. ctx.req.body = {
  469. name: 'file.md',
  470. }
  471. ctx.req.query = { type: 'markdown' }
  472. ctx.DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive =
  473. sinon.stub().resolves(ctx.archivePath)
  474. ctx.ProjectUploadManager.promises.createProjectFromZipArchive = sinon
  475. .stub()
  476. .resolves({
  477. _id: 'new-project-id',
  478. })
  479. await new Promise(resolve => {
  480. ctx.res.json = data => {
  481. expect(data.success).to.be.true
  482. expect(data.project_id).to.equal('new-project-id')
  483. resolve()
  484. }
  485. ctx.ProjectUploadController.importDocument(ctx.req, ctx.res)
  486. })
  487. })
  488. it('should call the DocumentConversionManager with file path and markdown type', function (ctx) {
  489. expect(
  490. ctx.DocumentConversionManager.promises
  491. .convertDocumentToLaTeXZipArchive
  492. ).to.have.been.calledWith(ctx.req.file.path, ctx.user_id, 'markdown')
  493. })
  494. it('should use the resulting archive to create a new project', function (ctx) {
  495. expect(
  496. ctx.ProjectUploadManager.promises.createProjectFromZipArchive
  497. ).to.have.been.calledWith(ctx.user_id, 'file', ctx.archivePath)
  498. })
  499. it('should set the compiler to lualatex', function (ctx) {
  500. expect(
  501. ctx.ProjectOptionsHandler.promises.setCompiler
  502. ).to.have.been.calledWith('new-project-id', 'lualatex')
  503. })
  504. it('should unlink the archive after creating the project', function (ctx) {
  505. expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.archivePath)
  506. })
  507. it('should unlink the uploaded file', function (ctx) {
  508. expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.req.file.path)
  509. })
  510. })
  511. describe('with an invalid conversionType', async function () {
  512. beforeEach(async function (ctx) {
  513. ctx.req.query = { type: 'invalid' }
  514. await new Promise(resolve => {
  515. ctx.res.json = data => {
  516. expect(data).to.deep.equal({
  517. success: false,
  518. error: 'invalid_type',
  519. })
  520. resolve()
  521. }
  522. ctx.ProjectUploadController.importDocument(ctx.req, ctx.res)
  523. })
  524. })
  525. it('should return http 400', function (ctx) {
  526. expect(ctx.res.statusCode).to.equal(400)
  527. })
  528. it('should not call DocumentConversionManager', function (ctx) {
  529. expect(
  530. ctx.DocumentConversionManager.promises
  531. .convertDocumentToLaTeXZipArchive
  532. ).not.to.have.been.called
  533. })
  534. })
  535. describe('unsuccessfully', async function () {
  536. beforeEach(async function (ctx) {
  537. ctx.DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive =
  538. sinon.stub().rejects(new Error('Conversion failed'))
  539. await new Promise(resolve => {
  540. ctx.res.json = data => {
  541. expect(data.success).to.be.false
  542. resolve()
  543. }
  544. ctx.ProjectUploadController.importDocument(ctx.req, ctx.res)
  545. })
  546. })
  547. it('should call the DocumentConversionManager to convert the file', function (ctx) {
  548. expect(
  549. ctx.DocumentConversionManager.promises
  550. .convertDocumentToLaTeXZipArchive
  551. ).to.have.been.calledWith(ctx.req.file.path, ctx.user_id, 'docx')
  552. })
  553. it('should unlink the uploaded file', function (ctx) {
  554. expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.req.file.path)
  555. })
  556. it('should return http 500', function (ctx) {
  557. expect(ctx.res.statusCode).to.equal(500)
  558. })
  559. })
  560. describe('when the converted archive is too large', async function () {
  561. beforeEach(async function (ctx) {
  562. ctx.DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive =
  563. sinon.stub().rejects(new FileTooLargeError('file too large'))
  564. await new Promise(resolve => {
  565. ctx.res.json = data => {
  566. expect(data).to.deep.equal({
  567. success: false,
  568. error: 'file_too_large',
  569. })
  570. resolve()
  571. }
  572. ctx.ProjectUploadController.importDocument(ctx.req, ctx.res)
  573. })
  574. })
  575. it('should return http 422', function (ctx) {
  576. expect(ctx.res.statusCode).to.equal(422)
  577. })
  578. it('should unlink the uploaded file', function (ctx) {
  579. expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.req.file.path)
  580. })
  581. })
  582. })
  583. })