CompileControllerTests.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. const SandboxedModule = require('sandboxed-module')
  2. const sinon = require('sinon')
  3. const { expect } = require('chai')
  4. const modulePath = require('node:path').join(
  5. __dirname,
  6. '../../../app/js/CompileController'
  7. )
  8. const Errors = require('../../../app/js/Errors')
  9. function tryImageNameValidation(method, imageNameField) {
  10. describe('when allowedImages is set', function () {
  11. beforeEach(function () {
  12. this.Settings.clsi = { docker: {} }
  13. this.Settings.clsi.docker.allowedImages = [
  14. 'repo/image:tag1',
  15. 'repo/image:tag2',
  16. ]
  17. this.res.send = sinon.stub()
  18. this.res.status = sinon.stub().returns({ send: this.res.send })
  19. this.CompileManager[method].reset()
  20. })
  21. describe('with an invalid image', function () {
  22. beforeEach(function () {
  23. this.req.query[imageNameField] = 'something/evil:1337'
  24. this.CompileController[method](this.req, this.res, this.next)
  25. })
  26. it('should return a 400', function () {
  27. expect(this.res.status.calledWith(400)).to.equal(true)
  28. })
  29. it('should not run the query', function () {
  30. expect(this.CompileManager[method].called).to.equal(false)
  31. })
  32. })
  33. describe('with a valid image', function () {
  34. beforeEach(function () {
  35. this.req.query[imageNameField] = 'repo/image:tag1'
  36. this.CompileController[method](this.req, this.res, this.next)
  37. })
  38. it('should not return a 400', function () {
  39. expect(this.res.status.calledWith(400)).to.equal(false)
  40. })
  41. it('should run the query', function () {
  42. expect(this.CompileManager[method].called).to.equal(true)
  43. })
  44. })
  45. })
  46. }
  47. describe('CompileController', function () {
  48. beforeEach(function () {
  49. this.buildId = 'build-id-123'
  50. this.CompileController = SandboxedModule.require(modulePath, {
  51. requires: {
  52. './CompileManager': (this.CompileManager = {}),
  53. './RequestParser': (this.RequestParser = {}),
  54. '@overleaf/settings': (this.Settings = {
  55. apis: {
  56. clsi: {
  57. url: 'http://clsi.example.com',
  58. outputUrlPrefix: '/zone/b',
  59. },
  60. },
  61. }),
  62. '@overleaf/metrics': {
  63. Timer: sinon.stub().returns({ done: sinon.stub() }),
  64. },
  65. './ProjectPersistenceManager': (this.ProjectPersistenceManager = {}),
  66. './Errors': (this.Erros = Errors),
  67. },
  68. })
  69. this.Settings.externalUrl = 'http://www.example.com'
  70. this.req = {}
  71. this.res = {}
  72. this.next = sinon.stub()
  73. })
  74. describe('compile', function () {
  75. beforeEach(function () {
  76. this.req.body = {
  77. compile: 'mock-body',
  78. }
  79. this.req.params = { project_id: (this.project_id = 'project-id-123') }
  80. this.request = {
  81. compile: 'mock-parsed-request',
  82. }
  83. this.request_with_project_id = {
  84. compile: this.request.compile,
  85. project_id: this.project_id,
  86. }
  87. this.output_files = [
  88. {
  89. path: 'output.pdf',
  90. type: 'pdf',
  91. size: 1337,
  92. build: 1234,
  93. },
  94. {
  95. path: 'output.log',
  96. type: 'log',
  97. build: 1234,
  98. },
  99. ]
  100. this.RequestParser.parse = sinon
  101. .stub()
  102. .callsArgWith(1, null, this.request)
  103. this.ProjectPersistenceManager.markProjectAsJustAccessed = sinon
  104. .stub()
  105. .callsArg(1)
  106. this.stats = { foo: 1 }
  107. this.timings = { bar: 2 }
  108. this.res.status = sinon.stub().returnsThis()
  109. this.res.send = sinon.stub()
  110. })
  111. describe('successfully', function () {
  112. beforeEach(function () {
  113. this.CompileManager.doCompileWithLock = sinon.stub().yields(null, {
  114. outputFiles: this.output_files,
  115. stats: this.stats,
  116. timings: this.timings,
  117. buildId: this.buildId,
  118. })
  119. this.CompileController.compile(this.req, this.res)
  120. })
  121. it('should parse the request', function () {
  122. this.RequestParser.parse.calledWith(this.req.body).should.equal(true)
  123. })
  124. it('should run the compile for the specified project', function () {
  125. this.CompileManager.doCompileWithLock
  126. .calledWith(this.request_with_project_id)
  127. .should.equal(true)
  128. })
  129. it('should mark the project as accessed', function () {
  130. this.ProjectPersistenceManager.markProjectAsJustAccessed
  131. .calledWith(this.project_id)
  132. .should.equal(true)
  133. })
  134. it('should return the JSON response', function () {
  135. this.res.status.calledWith(200).should.equal(true)
  136. this.res.send
  137. .calledWith({
  138. compile: {
  139. status: 'success',
  140. error: null,
  141. stats: this.stats,
  142. timings: this.timings,
  143. buildId: this.buildId,
  144. outputUrlPrefix: '/zone/b',
  145. outputFiles: this.output_files.map(file => ({
  146. url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`,
  147. ...file,
  148. })),
  149. },
  150. })
  151. .should.equal(true)
  152. })
  153. })
  154. describe('without a outputUrlPrefix', function () {
  155. beforeEach(function () {
  156. this.Settings.apis.clsi.outputUrlPrefix = ''
  157. this.CompileManager.doCompileWithLock = sinon.stub().yields(null, {
  158. outputFiles: this.output_files,
  159. stats: this.stats,
  160. timings: this.timings,
  161. buildId: this.buildId,
  162. })
  163. this.CompileController.compile(this.req, this.res)
  164. })
  165. it('should return the JSON response with empty outputUrlPrefix', function () {
  166. this.res.status.calledWith(200).should.equal(true)
  167. this.res.send
  168. .calledWith({
  169. compile: {
  170. status: 'success',
  171. error: null,
  172. stats: this.stats,
  173. timings: this.timings,
  174. buildId: this.buildId,
  175. outputUrlPrefix: '',
  176. outputFiles: this.output_files.map(file => ({
  177. url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`,
  178. ...file,
  179. })),
  180. },
  181. })
  182. .should.equal(true)
  183. })
  184. })
  185. describe('with user provided fake_output.pdf', function () {
  186. beforeEach(function () {
  187. this.output_files = [
  188. {
  189. path: 'fake_output.pdf',
  190. type: 'pdf',
  191. build: 1234,
  192. },
  193. {
  194. path: 'output.log',
  195. type: 'log',
  196. build: 1234,
  197. },
  198. ]
  199. this.CompileManager.doCompileWithLock = sinon.stub().yields(null, {
  200. outputFiles: this.output_files,
  201. stats: this.stats,
  202. timings: this.timings,
  203. buildId: this.buildId,
  204. })
  205. this.CompileController.compile(this.req, this.res)
  206. })
  207. it('should return the JSON response with status failure', function () {
  208. this.res.status.calledWith(200).should.equal(true)
  209. this.res.send
  210. .calledWith({
  211. compile: {
  212. status: 'failure',
  213. error: null,
  214. stats: this.stats,
  215. timings: this.timings,
  216. outputUrlPrefix: '/zone/b',
  217. buildId: this.buildId,
  218. outputFiles: this.output_files.map(file => ({
  219. url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`,
  220. ...file,
  221. })),
  222. },
  223. })
  224. .should.equal(true)
  225. })
  226. })
  227. describe('with an empty output.pdf', function () {
  228. beforeEach(function () {
  229. this.output_files = [
  230. {
  231. path: 'output.pdf',
  232. type: 'pdf',
  233. size: 0,
  234. build: 1234,
  235. },
  236. {
  237. path: 'output.log',
  238. type: 'log',
  239. build: 1234,
  240. },
  241. ]
  242. this.CompileManager.doCompileWithLock = sinon.stub().yields(null, {
  243. outputFiles: this.output_files,
  244. stats: this.stats,
  245. timings: this.timings,
  246. buildId: this.buildId,
  247. })
  248. this.CompileController.compile(this.req, this.res)
  249. })
  250. it('should return the JSON response with status failure', function () {
  251. this.res.status.calledWith(200).should.equal(true)
  252. this.res.send
  253. .calledWith({
  254. compile: {
  255. status: 'failure',
  256. error: null,
  257. stats: this.stats,
  258. buildId: this.buildId,
  259. timings: this.timings,
  260. outputUrlPrefix: '/zone/b',
  261. outputFiles: this.output_files.map(file => ({
  262. url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`,
  263. ...file,
  264. })),
  265. },
  266. })
  267. .should.equal(true)
  268. })
  269. })
  270. describe('with an error', function () {
  271. beforeEach(function () {
  272. const error = new Error((this.message = 'error message'))
  273. error.buildId = this.buildId
  274. this.CompileManager.doCompileWithLock = sinon
  275. .stub()
  276. .callsArgWith(1, error, null)
  277. this.CompileController.compile(this.req, this.res)
  278. })
  279. it('should return the JSON response with the error', function () {
  280. this.res.status.calledWith(500).should.equal(true)
  281. this.res.send
  282. .calledWith({
  283. compile: {
  284. status: 'error',
  285. error: this.message,
  286. outputUrlPrefix: '/zone/b',
  287. outputFiles: [],
  288. buildId: this.buildId,
  289. // JSON.stringify will omit these
  290. stats: undefined,
  291. timings: undefined,
  292. },
  293. })
  294. .should.equal(true)
  295. })
  296. })
  297. describe('with too many compile requests error', function () {
  298. beforeEach(function () {
  299. const error = new Errors.TooManyCompileRequestsError(
  300. 'too many concurrent compile requests'
  301. )
  302. this.CompileManager.doCompileWithLock = sinon
  303. .stub()
  304. .callsArgWith(1, error, null)
  305. this.CompileController.compile(this.req, this.res)
  306. })
  307. it('should return the JSON response with the error', function () {
  308. this.res.status.calledWith(503).should.equal(true)
  309. this.res.send
  310. .calledWith({
  311. compile: {
  312. status: 'unavailable',
  313. error: 'too many concurrent compile requests',
  314. outputUrlPrefix: '/zone/b',
  315. outputFiles: [],
  316. buildId: undefined,
  317. stats: undefined,
  318. timings: undefined,
  319. },
  320. })
  321. .should.equal(true)
  322. })
  323. })
  324. describe('when the request times out', function () {
  325. beforeEach(function () {
  326. this.error = new Error((this.message = 'container timed out'))
  327. this.error.timedout = true
  328. this.CompileManager.doCompileWithLock = sinon
  329. .stub()
  330. .callsArgWith(1, this.error, null)
  331. this.CompileController.compile(this.req, this.res)
  332. })
  333. it('should return the JSON response with the timeout status', function () {
  334. this.res.status.calledWith(200).should.equal(true)
  335. this.res.send
  336. .calledWith({
  337. compile: {
  338. status: 'timedout',
  339. error: this.message,
  340. outputUrlPrefix: '/zone/b',
  341. outputFiles: [],
  342. // JSON.stringify will omit these
  343. buildId: undefined,
  344. stats: undefined,
  345. timings: undefined,
  346. },
  347. })
  348. .should.equal(true)
  349. })
  350. })
  351. describe('when the request returns no output files', function () {
  352. beforeEach(function () {
  353. this.CompileManager.doCompileWithLock = sinon
  354. .stub()
  355. .callsArgWith(1, null, [])
  356. this.CompileController.compile(this.req, this.res)
  357. })
  358. it('should return the JSON response with the failure status', function () {
  359. this.res.status.calledWith(200).should.equal(true)
  360. this.res.send
  361. .calledWith({
  362. compile: {
  363. error: null,
  364. status: 'failure',
  365. outputUrlPrefix: '/zone/b',
  366. outputFiles: [],
  367. // JSON.stringify will omit these
  368. buildId: undefined,
  369. stats: undefined,
  370. timings: undefined,
  371. },
  372. })
  373. .should.equal(true)
  374. })
  375. })
  376. })
  377. describe('syncFromCode', function () {
  378. beforeEach(function () {
  379. this.file = 'main.tex'
  380. this.line = 42
  381. this.column = 5
  382. this.project_id = 'mock-project-id'
  383. this.req.params = { project_id: this.project_id }
  384. this.req.query = {
  385. file: this.file,
  386. line: this.line.toString(),
  387. column: this.column.toString(),
  388. }
  389. this.res.json = sinon.stub()
  390. this.CompileManager.syncFromCode = sinon
  391. .stub()
  392. .yields(null, (this.pdfPositions = ['mock-positions']))
  393. this.CompileController.syncFromCode(this.req, this.res, this.next)
  394. })
  395. it('should find the corresponding location in the PDF', function () {
  396. this.CompileManager.syncFromCode
  397. .calledWith(
  398. this.project_id,
  399. undefined,
  400. this.file,
  401. this.line,
  402. this.column
  403. )
  404. .should.equal(true)
  405. })
  406. it('should return the positions', function () {
  407. this.res.json
  408. .calledWith({
  409. pdf: this.pdfPositions,
  410. })
  411. .should.equal(true)
  412. })
  413. tryImageNameValidation('syncFromCode', 'imageName')
  414. })
  415. describe('syncFromPdf', function () {
  416. beforeEach(function () {
  417. this.page = 5
  418. this.h = 100.23
  419. this.v = 45.67
  420. this.project_id = 'mock-project-id'
  421. this.req.params = { project_id: this.project_id }
  422. this.req.query = {
  423. page: this.page.toString(),
  424. h: this.h.toString(),
  425. v: this.v.toString(),
  426. }
  427. this.res.json = sinon.stub()
  428. this.CompileManager.syncFromPdf = sinon
  429. .stub()
  430. .yields(null, (this.codePositions = ['mock-positions']))
  431. this.CompileController.syncFromPdf(this.req, this.res, this.next)
  432. })
  433. it('should find the corresponding location in the code', function () {
  434. this.CompileManager.syncFromPdf
  435. .calledWith(this.project_id, undefined, this.page, this.h, this.v)
  436. .should.equal(true)
  437. })
  438. it('should return the positions', function () {
  439. this.res.json
  440. .calledWith({
  441. code: this.codePositions,
  442. })
  443. .should.equal(true)
  444. })
  445. tryImageNameValidation('syncFromPdf', 'imageName')
  446. })
  447. describe('wordcount', function () {
  448. beforeEach(function () {
  449. this.file = 'main.tex'
  450. this.project_id = 'mock-project-id'
  451. this.req.params = { project_id: this.project_id }
  452. this.req.query = {
  453. file: this.file,
  454. image: (this.image = 'example.com/image'),
  455. }
  456. this.res.json = sinon.stub()
  457. this.CompileManager.wordcount = sinon
  458. .stub()
  459. .callsArgWith(4, null, (this.texcount = ['mock-texcount']))
  460. })
  461. it('should return the word count of a file', function () {
  462. this.CompileController.wordcount(this.req, this.res, this.next)
  463. this.CompileManager.wordcount
  464. .calledWith(this.project_id, undefined, this.file, this.image)
  465. .should.equal(true)
  466. })
  467. it('should return the texcount info', function () {
  468. this.CompileController.wordcount(this.req, this.res, this.next)
  469. this.res.json
  470. .calledWith({
  471. texcount: this.texcount,
  472. })
  473. .should.equal(true)
  474. })
  475. tryImageNameValidation('wordcount', 'image')
  476. })
  477. })