CompileController.test.mjs 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060
  1. import { vi, expect } from 'vitest'
  2. import sinon from 'sinon'
  3. import MockRequest from '../helpers/MockRequest.mjs'
  4. import MockResponse from '../helpers/MockResponse.mjs'
  5. import { Headers } from 'node-fetch'
  6. import { ReadableString } from '@overleaf/stream-utils'
  7. import { RequestFailedError } from '@overleaf/fetch-utils'
  8. import { asZodError } from '@overleaf/validation-tools/testUtils.js'
  9. const modulePath = '../../../../app/src/Features/Compile/CompileController.mjs'
  10. describe('CompileController', function () {
  11. beforeEach(async function (ctx) {
  12. ctx.user_id = 'aaaaaaaaaaaaaaaaaaaaaaaa'
  13. ctx.user = {
  14. _id: ctx.user_id,
  15. email: 'user@example.com',
  16. features: {
  17. compileGroup: 'premium',
  18. compileTimeout: 100,
  19. },
  20. }
  21. ctx.ClsiCacheController = {
  22. _downloadFromCacheWithParams: sinon.stub().resolves(),
  23. }
  24. ctx.CompileManager = {
  25. promises: {
  26. compile: sinon.stub(),
  27. getProjectCompileLimits: sinon.stub().resolves({
  28. compileBackendClass: 'c3d',
  29. compileGroup: 'standard',
  30. }),
  31. syncTeX: sinon.stub(),
  32. },
  33. }
  34. ctx.ClsiManager = {
  35. promises: {},
  36. }
  37. ctx.UserGetter = { getUser: sinon.stub() }
  38. ctx.rateLimiter = {
  39. consume: sinon.stub().resolves(),
  40. }
  41. ctx.RateLimiter = {
  42. RateLimiter: sinon.stub().returns(ctx.rateLimiter),
  43. }
  44. ctx.settings = {
  45. apis: {
  46. clsi: {
  47. url: 'http://clsi.example.com:3013',
  48. downloadHost: 'http://clsi.example.com:8080',
  49. submissionBackendClass: 'c3d',
  50. },
  51. clsi_priority: {
  52. url: 'http://clsi-priority.example.com',
  53. },
  54. },
  55. defaultFeatures: {
  56. compileGroup: 'standard',
  57. compileTimeout: 60,
  58. },
  59. clsiCookie: {
  60. key: 'cookie-key',
  61. },
  62. moduleImportSequence: [],
  63. overleaf: {},
  64. }
  65. ctx.ClsiCookieManager = {
  66. promises: {
  67. getServerId: sinon.stub().resolves('clsi-server-id-from-redis'),
  68. },
  69. }
  70. ctx.SessionManager = {
  71. getLoggedInUserId: sinon.stub().returns(ctx.user_id),
  72. getSessionUser: sinon.stub().returns(ctx.user),
  73. isUserLoggedIn: sinon.stub().returns(true),
  74. }
  75. ctx.pipeline = sinon.stub().callsFake(async (stream, res) => {
  76. if (res.callback) res.callback()
  77. })
  78. ctx.clsiStream = new ReadableString('{}')
  79. ctx.clsiResponse = {
  80. headers: new Headers({
  81. 'Content-Length': '2',
  82. 'Content-Type': 'application/json',
  83. }),
  84. }
  85. ctx.fetchUtils = {
  86. fetchStreamWithResponse: sinon.stub().resolves({
  87. stream: ctx.clsiStream,
  88. response: ctx.clsiResponse,
  89. }),
  90. RequestFailedError,
  91. }
  92. vi.doMock('stream/promises', () => ({
  93. pipeline: ctx.pipeline,
  94. }))
  95. ctx.Metrics = {
  96. inc: sinon.stub(),
  97. Timer: sinon.stub().returns({ done: sinon.stub(), labels: {} }),
  98. }
  99. vi.doMock('@overleaf/metrics', () => ({ default: ctx.Metrics }))
  100. vi.doMock('@overleaf/settings', () => ({
  101. default: ctx.settings,
  102. }))
  103. vi.doMock('@overleaf/fetch-utils', () => ctx.fetchUtils)
  104. vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
  105. default: (ctx.ProjectGetter = {
  106. promises: {},
  107. }),
  108. }))
  109. vi.doMock(
  110. '../../../../app/src/Features/Compile/ClsiCacheController',
  111. () => ({
  112. default: ctx.ClsiCacheController,
  113. })
  114. )
  115. vi.doMock('../../../../app/src/Features/Compile/CompileManager', () => ({
  116. default: ctx.CompileManager,
  117. }))
  118. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  119. default: ctx.UserGetter,
  120. }))
  121. vi.doMock('../../../../app/src/Features/Compile/ClsiManager', () => ({
  122. default: ctx.ClsiManager,
  123. }))
  124. vi.doMock(
  125. '../../../../app/src/Features/Authentication/SessionManager',
  126. () => ({
  127. default: ctx.SessionManager,
  128. })
  129. )
  130. vi.doMock(
  131. '../../../../app/src/infrastructure/RateLimiter.mjs',
  132. () => ctx.RateLimiter
  133. )
  134. vi.doMock('../../../../app/src/Features/Compile/ClsiCookieManager', () => ({
  135. default: () => ctx.ClsiCookieManager,
  136. }))
  137. vi.doMock(
  138. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  139. () => ({
  140. default: {
  141. featureFlagEnabled: (ctx.featureFlagEnabled = sinon
  142. .stub()
  143. .yields(null, false)),
  144. promises: {
  145. featureFlagEnabled: sinon.stub().resolves(false),
  146. },
  147. },
  148. })
  149. )
  150. vi.doMock(
  151. '../../../../app/src/Features/Analytics/AnalyticsManager',
  152. () => ({
  153. default: {
  154. recordEventForSession: sinon.stub(),
  155. },
  156. })
  157. )
  158. ctx.CompileController = (await import(modulePath)).default
  159. ctx.projectId = 'abc123def456abc123def456'
  160. ctx.build_id = '18fbe9e7564-30dcb2f71250c690'
  161. ctx.next = sinon.stub().callsFake(err => {
  162. // Flag unexpected next calls.
  163. throw err
  164. })
  165. ctx.req = new MockRequest(vi)
  166. ctx.res = new MockResponse(vi)
  167. ctx.res = new MockResponse(vi)
  168. })
  169. describe('compile', function () {
  170. beforeEach(function (ctx) {
  171. ctx.req.params = { Project_id: ctx.projectId }
  172. ctx.req.session = {}
  173. ctx.CompileManager.promises.compile = sinon.stub().resolves({
  174. status: (ctx.status = 'success'),
  175. outputFiles: (ctx.outputFiles = [
  176. {
  177. path: 'output.pdf',
  178. url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/id/output.pdf`,
  179. type: 'pdf',
  180. },
  181. ]),
  182. clsiServerId: undefined,
  183. limits: undefined,
  184. validationProblems: undefined,
  185. stats: undefined,
  186. timings: undefined,
  187. outputUrlPrefix: undefined,
  188. buildId: ctx.build_id,
  189. })
  190. })
  191. describe('pdfDownloadDomain', function () {
  192. beforeEach(function (ctx) {
  193. ctx.settings.pdfDownloadDomain = 'https://compiles.overleaf.test'
  194. })
  195. describe('when clsi does not emit zone prefix', function () {
  196. beforeEach(async function (ctx) {
  197. await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
  198. })
  199. it('should add domain verbatim', function (ctx) {
  200. ctx.res.statusCode.should.equal(200)
  201. ctx.res.body.should.equal(
  202. JSON.stringify({
  203. status: ctx.status,
  204. outputFiles: [
  205. {
  206. path: 'output.pdf',
  207. url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/id/output.pdf`,
  208. type: 'pdf',
  209. },
  210. ],
  211. outputFilesArchive: {
  212. path: 'output.zip',
  213. url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/${ctx.build_id}/output/output.zip`,
  214. type: 'zip',
  215. },
  216. pdfDownloadDomain: 'https://compiles.overleaf.test',
  217. })
  218. )
  219. })
  220. })
  221. describe('when clsi emits a zone prefix', function () {
  222. beforeEach(async function (ctx) {
  223. ctx.CompileManager.promises.compile = sinon.stub().resolves({
  224. status: (ctx.status = 'success'),
  225. outputFiles: (ctx.outputFiles = [
  226. {
  227. path: 'output.pdf',
  228. url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/id/output.pdf`,
  229. type: 'pdf',
  230. },
  231. ]),
  232. clsiServerId: undefined,
  233. limits: undefined,
  234. validationProblems: undefined,
  235. stats: undefined,
  236. timings: undefined,
  237. outputUrlPrefix: '/zone/b',
  238. buildId: ctx.build_id,
  239. })
  240. await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
  241. })
  242. it('should add the zone prefix', function (ctx) {
  243. ctx.res.statusCode.should.equal(200)
  244. ctx.res.body.should.equal(
  245. JSON.stringify({
  246. status: ctx.status,
  247. outputFiles: [
  248. {
  249. path: 'output.pdf',
  250. url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/id/output.pdf`,
  251. type: 'pdf',
  252. },
  253. ],
  254. outputFilesArchive: {
  255. path: 'output.zip',
  256. url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/${ctx.build_id}/output/output.zip`,
  257. type: 'zip',
  258. },
  259. outputUrlPrefix: '/zone/b',
  260. pdfDownloadDomain: 'https://compiles.overleaf.test/zone/b',
  261. })
  262. )
  263. })
  264. })
  265. })
  266. describe('when not an auto compile', function () {
  267. beforeEach(async function (ctx) {
  268. await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
  269. })
  270. it('should look up the user id', function (ctx) {
  271. ctx.SessionManager.getLoggedInUserId
  272. .calledWith(ctx.req.session)
  273. .should.equal(true)
  274. })
  275. it('should do the compile without the auto compile flag', function (ctx) {
  276. expect(ctx.CompileManager.promises.compile).to.have.been.calledWith(
  277. ctx.projectId,
  278. ctx.user_id,
  279. {
  280. isAutoCompile: false,
  281. compileFromClsiCache: true,
  282. populateClsiCache: true,
  283. compileFromHistory: false,
  284. enablePdfCaching: false,
  285. fileLineErrors: false,
  286. stopOnFirstError: false,
  287. editorId: undefined,
  288. rootResourcePath: undefined,
  289. }
  290. )
  291. })
  292. it('should set the content-type of the response to application/json', function (ctx) {
  293. ctx.res.type.should.equal('application/json')
  294. })
  295. it('should send a successful response reporting the status and files', function (ctx) {
  296. ctx.res.statusCode.should.equal(200)
  297. ctx.res.body.should.equal(
  298. JSON.stringify({
  299. status: ctx.status,
  300. outputFiles: ctx.outputFiles,
  301. outputFilesArchive: {
  302. path: 'output.zip',
  303. url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/${ctx.build_id}/output/output.zip`,
  304. type: 'zip',
  305. },
  306. })
  307. )
  308. })
  309. })
  310. describe('when an auto compile', function () {
  311. beforeEach(async function (ctx) {
  312. ctx.req.query = { auto_compile: 'true' }
  313. await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
  314. })
  315. it('should do the compile with the auto compile flag', function (ctx) {
  316. ctx.CompileManager.promises.compile.should.have.been.calledWith(
  317. ctx.projectId,
  318. ctx.user_id,
  319. {
  320. isAutoCompile: true,
  321. compileFromClsiCache: true,
  322. populateClsiCache: true,
  323. compileFromHistory: false,
  324. enablePdfCaching: false,
  325. fileLineErrors: false,
  326. stopOnFirstError: false,
  327. editorId: undefined,
  328. rootResourcePath: undefined,
  329. }
  330. )
  331. })
  332. })
  333. describe('with the draft attribute', function () {
  334. beforeEach(async function (ctx) {
  335. ctx.req.body = { draft: true }
  336. await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
  337. })
  338. it('should do the compile without the draft compile flag', function (ctx) {
  339. ctx.CompileManager.promises.compile.should.have.been.calledWith(
  340. ctx.projectId,
  341. ctx.user_id,
  342. {
  343. isAutoCompile: false,
  344. compileFromClsiCache: true,
  345. populateClsiCache: true,
  346. compileFromHistory: false,
  347. enablePdfCaching: false,
  348. draft: true,
  349. fileLineErrors: false,
  350. stopOnFirstError: false,
  351. editorId: undefined,
  352. rootResourcePath: undefined,
  353. }
  354. )
  355. })
  356. })
  357. describe('with an editor id', function () {
  358. beforeEach(async function (ctx) {
  359. ctx.req.body = { editorId: 'the-editor-id' }
  360. await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
  361. })
  362. it('should pass the editor id to the compiler', function (ctx) {
  363. ctx.CompileManager.promises.compile.should.have.been.calledWith(
  364. ctx.projectId,
  365. ctx.user_id,
  366. {
  367. isAutoCompile: false,
  368. compileFromClsiCache: true,
  369. populateClsiCache: true,
  370. compileFromHistory: false,
  371. enablePdfCaching: false,
  372. fileLineErrors: false,
  373. stopOnFirstError: false,
  374. editorId: 'the-editor-id',
  375. rootResourcePath: undefined,
  376. }
  377. )
  378. })
  379. })
  380. describe('with a rootResourcePath', function () {
  381. beforeEach(async function (ctx) {
  382. ctx.req.body = { rootResourcePath: 'foo.tex' }
  383. await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
  384. })
  385. it('should pass the rootResourcePath to the compiler', function (ctx) {
  386. ctx.CompileManager.promises.compile.should.have.been.calledWith(
  387. ctx.projectId,
  388. ctx.user_id,
  389. {
  390. isAutoCompile: false,
  391. compileFromClsiCache: true,
  392. populateClsiCache: true,
  393. compileFromHistory: false,
  394. enablePdfCaching: false,
  395. fileLineErrors: false,
  396. stopOnFirstError: false,
  397. editorId: undefined,
  398. rootResourcePath: 'foo.tex',
  399. }
  400. )
  401. })
  402. })
  403. })
  404. describe('compileSubmission', function () {
  405. beforeEach(function (ctx) {
  406. ctx.submission_id = 'sub-1234'
  407. ctx.req.params = { submission_id: ctx.submission_id }
  408. ctx.req.body = {}
  409. ctx.ClsiManager.promises.sendExternalRequest = sinon.stub().resolves({
  410. status: (ctx.status = 'success'),
  411. outputFiles: (ctx.outputFiles = ['mock-output-files']),
  412. clsiServerId: 'mock-server-id',
  413. validationProblems: null,
  414. })
  415. })
  416. it('should set the content-type of the response to application/json', async function (ctx) {
  417. await ctx.CompileController.compileSubmission(ctx.req, ctx.res, ctx.next)
  418. expect(ctx.res.contentType).toBeCalledWith('application/json')
  419. })
  420. it('should send a successful response reporting the status and files', async function (ctx) {
  421. await ctx.CompileController.compileSubmission(ctx.req, ctx.res, ctx.next)
  422. ctx.res.statusCode.should.equal(200)
  423. ctx.res.body.should.equal(
  424. JSON.stringify({
  425. status: ctx.status,
  426. outputFiles: ctx.outputFiles,
  427. clsiServerId: 'mock-server-id',
  428. validationProblems: null,
  429. })
  430. )
  431. })
  432. describe('with compileGroup and timeout', function () {
  433. beforeEach(function (ctx) {
  434. ctx.req.body = {
  435. compileGroup: 'special',
  436. timeout: 600,
  437. }
  438. ctx.CompileController.compileSubmission(ctx.req, ctx.res, ctx.next)
  439. })
  440. it('should use the supplied values', function (ctx) {
  441. ctx.ClsiManager.promises.sendExternalRequest.should.have.been.calledWith(
  442. ctx.submission_id,
  443. { compileGroup: 'special', timeout: 600 },
  444. { compileGroup: 'special', compileBackendClass: 'c3d', timeout: 600 }
  445. )
  446. })
  447. })
  448. describe('with other supported options but not compileGroup and timeout', function () {
  449. beforeEach(function (ctx) {
  450. ctx.req.body = {
  451. rootResourcePath: 'main.tex',
  452. compiler: 'lualatex',
  453. draft: true,
  454. check: 'validate',
  455. }
  456. ctx.CompileController.compileSubmission(ctx.req, ctx.res, ctx.next)
  457. })
  458. it('should use the other options but default values for compileGroup and timeout', function (ctx) {
  459. ctx.ClsiManager.promises.sendExternalRequest.should.have.been.calledWith(
  460. ctx.submission_id,
  461. {
  462. rootResourcePath: 'main.tex',
  463. compiler: 'lualatex',
  464. draft: true,
  465. check: 'validate',
  466. },
  467. {
  468. rootResourcePath: 'main.tex',
  469. compiler: 'lualatex',
  470. draft: true,
  471. check: 'validate',
  472. compileGroup: 'standard',
  473. compileBackendClass: 'c3d',
  474. timeout: 60,
  475. }
  476. )
  477. })
  478. })
  479. })
  480. describe('downloadPdf', function () {
  481. beforeEach(function (ctx) {
  482. ctx.clsiServerId = 'clsi-server-1'
  483. ctx.req.params = {
  484. Project_id: ctx.projectId,
  485. build_id: ctx.build_id,
  486. }
  487. ctx.req.query = { clsiserverid: ctx.clsiServerId }
  488. ctx.req.session = {}
  489. ctx.project = { name: 'test namè; 1' }
  490. ctx.ProjectGetter.promises.getProject = sinon.stub().resolves(ctx.project)
  491. })
  492. describe('logged-in', function () {
  493. beforeEach(async function (ctx) {
  494. await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
  495. })
  496. it('should look up the project', function (ctx) {
  497. ctx.ProjectGetter.promises.getProject
  498. .calledWith(ctx.projectId, { name: 1 })
  499. .should.equal(true)
  500. })
  501. it('should set the content-type of the response to application/pdf', function (ctx) {
  502. expect(ctx.res.contentType).toBeCalledWith('application/pdf')
  503. })
  504. it('should set the content-disposition header with a safe version of the project name', function (ctx) {
  505. expect(ctx.res.setContentDisposition).toBeCalledWith('inline', {
  506. filename: 'test_namè__1.pdf',
  507. })
  508. })
  509. it('should increment the pdf-downloads metric', function (ctx) {
  510. ctx.Metrics.inc.calledWith('pdf-downloads').should.equal(true)
  511. })
  512. it('should proxy the PDF from the CLSI', function (ctx) {
  513. ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
  514. `${ctx.settings.apis.clsi.downloadHost}/project/${ctx.projectId}/user/${ctx.user_id}/build/${ctx.build_id}/output/output.pdf?clsiserverid=${ctx.clsiServerId}`
  515. )
  516. })
  517. })
  518. describe('anon', function () {
  519. beforeEach(async function (ctx) {
  520. ctx.SessionManager.getLoggedInUserId.returns(null)
  521. await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
  522. })
  523. it('should proxy the PDF from the CLSI', function (ctx) {
  524. ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
  525. `${ctx.settings.apis.clsi.downloadHost}/project/${ctx.projectId}/build/${ctx.build_id}/output/output.pdf?clsiserverid=${ctx.clsiServerId}`
  526. )
  527. })
  528. })
  529. describe('when rate-limited', function () {
  530. beforeEach(async function (ctx) {
  531. ctx.rateLimiter.consume.rejects({
  532. msBeforeNext: 250,
  533. remainingPoints: 0,
  534. consumedPoints: 5,
  535. isFirstInDuration: false,
  536. })
  537. })
  538. it('should return 500', async function (ctx) {
  539. await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
  540. expect(ctx.res.status).toBeCalledWith(429)
  541. ctx.fetchUtils.fetchStreamWithResponse.should.not.have.been.called
  542. })
  543. })
  544. describe('when rate-limit errors', function () {
  545. beforeEach(async function (ctx) {
  546. ctx.rateLimiter.consume.rejects(new Error('uh oh'))
  547. })
  548. it('should return 500', async function (ctx) {
  549. await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
  550. expect(ctx.res.status).toBeCalledWith(500)
  551. ctx.fetchUtils.fetchStreamWithResponse.should.not.have.been.called
  552. })
  553. })
  554. })
  555. describe('getOutputZipFromClsi', function () {
  556. beforeEach(function (ctx) {
  557. ctx.clsiServerId = 'clsi-server-1'
  558. ctx.req.params = {
  559. Project_id: ctx.projectId,
  560. build_id: ctx.build_id,
  561. }
  562. ctx.req.query = { clsiserverid: ctx.clsiServerId }
  563. ctx.req.session = {}
  564. ctx.project = { name: 'test namè; 1' }
  565. ctx.ProjectGetter.promises.getProject = sinon.stub().resolves(ctx.project)
  566. })
  567. describe('free user', function () {
  568. beforeEach(async function (ctx) {
  569. await ctx.CompileController.getOutputZipFromClsi(
  570. ctx.req,
  571. ctx.res,
  572. ctx.next
  573. )
  574. })
  575. it('should look up the project', function (ctx) {
  576. ctx.ProjectGetter.promises.getProject
  577. .calledWith(ctx.projectId, { name: 1 })
  578. .should.equal(true)
  579. })
  580. it('should set the content-type of the response to application/zip', function (ctx) {
  581. expect(ctx.res.contentType).toBeCalledWith('application/zip')
  582. })
  583. it('should set the content-disposition header with a safe version of the project name', function (ctx) {
  584. expect(ctx.res.headers['Content-Disposition']).toEqual(
  585. 'attachment; filename="test_namè__1-output.zip"'
  586. )
  587. })
  588. it('should proxy the PDF from the CLSI', function (ctx) {
  589. ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
  590. `${ctx.settings.apis.clsi.url}/project/${ctx.projectId}/user/${ctx.user_id}/build/${ctx.build_id}/output/output.zip?compileBackendClass=c3d&clsiserverid=${ctx.clsiServerId}`
  591. )
  592. })
  593. })
  594. describe('premium user', function () {
  595. beforeEach(async function (ctx) {
  596. ctx.CompileManager.promises.getProjectCompileLimits = sinon
  597. .stub()
  598. .resolves({
  599. compileGroup: 'priority',
  600. compileBackendClass: 'c4d',
  601. })
  602. await ctx.CompileController.getOutputZipFromClsi(
  603. ctx.req,
  604. ctx.res,
  605. ctx.next
  606. )
  607. })
  608. it('should proxy the PDF from the CLSI', function (ctx) {
  609. ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
  610. `${ctx.settings.apis.clsi.url}/project/${ctx.projectId}/user/${ctx.user_id}/build/${ctx.build_id}/output/output.zip?compileBackendClass=c4d&clsiserverid=${ctx.clsiServerId}`
  611. )
  612. })
  613. })
  614. })
  615. describe('getFileForSubmissionFromClsi', function () {
  616. beforeEach(function (ctx) {
  617. ctx.submission_id = 'sub-1234'
  618. ctx.clsiServerId = 'clsi-server-1'
  619. ctx.file = 'output.pdf'
  620. ctx.req.params = {
  621. submissionId: ctx.submission_id,
  622. build_id: ctx.build_id,
  623. file: ctx.file,
  624. }
  625. ctx.req.query = { clsiserverid: ctx.clsiServerId }
  626. })
  627. describe('proxy to CLSI with correct URL', function () {
  628. beforeEach(async function (ctx) {
  629. await ctx.CompileController.getFileForSubmissionFromClsi(
  630. ctx.req,
  631. ctx.res,
  632. ctx.next
  633. )
  634. })
  635. it('should proxy to CLSI with correct URL', function (ctx) {
  636. const expectedUrl = `${ctx.settings.apis.clsi.downloadHost}/project/${ctx.submission_id}/build/${ctx.build_id}/output/${ctx.file}?clsiserverid=${ctx.clsiServerId}`
  637. ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
  638. expectedUrl
  639. )
  640. })
  641. })
  642. })
  643. describe('proxySyncCode', function () {
  644. let file, line, column, imageName, editorId, buildId, clsiServerId
  645. beforeEach(async function (ctx) {
  646. ctx.req.params = { Project_id: ctx.projectId }
  647. clsiServerId = 'clsi-1'
  648. file = 'main.tex'
  649. line = String(Date.now())
  650. column = String(Date.now() + 1)
  651. editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
  652. buildId = '195b4a3f9e7-03e5be430a9e7796'
  653. ctx.req.query = {
  654. file,
  655. line,
  656. column,
  657. editorId,
  658. buildId,
  659. clsiserverid: clsiServerId,
  660. }
  661. imageName = 'foo/bar:tag-0'
  662. ctx.ProjectGetter.promises.getProject = sinon
  663. .stub()
  664. .resolves({ imageName })
  665. ctx.CompileController._proxyToClsi = sinon.stub().resolves()
  666. await ctx.CompileController.proxySyncCode(ctx.req, ctx.res, ctx.next)
  667. })
  668. it('should parse the parameters', function (ctx) {
  669. expect(ctx.CompileManager.promises.syncTeX).to.have.been.calledWith(
  670. ctx.projectId,
  671. ctx.user_id,
  672. {
  673. direction: 'code',
  674. compileFromClsiCache: true,
  675. validatedOptions: {
  676. file,
  677. line,
  678. column,
  679. editorId,
  680. buildId,
  681. },
  682. clsiServerId,
  683. }
  684. )
  685. })
  686. })
  687. describe('proxySyncPdf', function () {
  688. let page, h, v, imageName, editorId, buildId, clsiServerId
  689. beforeEach(async function (ctx) {
  690. ctx.req.params = { Project_id: ctx.projectId }
  691. clsiServerId = 'clsi-1'
  692. page = String(Date.now())
  693. h = String(Math.random())
  694. v = String(Math.random())
  695. editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
  696. buildId = '195b4a3f9e7-03e5be430a9e7796'
  697. ctx.req.query = {
  698. page,
  699. h,
  700. v,
  701. editorId,
  702. buildId,
  703. clsiserverid: clsiServerId,
  704. }
  705. imageName = 'foo/bar:tag-1'
  706. ctx.ProjectGetter.promises.getProject = sinon
  707. .stub()
  708. .resolves({ imageName })
  709. ctx.CompileController._proxyToClsi = sinon.stub()
  710. await ctx.CompileController.proxySyncPdf(ctx.req, ctx.res, ctx.next)
  711. })
  712. it('should parse the parameters', function (ctx) {
  713. expect(ctx.CompileManager.promises.syncTeX).to.have.been.calledWith(
  714. ctx.projectId,
  715. ctx.user_id,
  716. {
  717. direction: 'pdf',
  718. compileFromClsiCache: true,
  719. validatedOptions: {
  720. page,
  721. h,
  722. v,
  723. editorId,
  724. buildId,
  725. },
  726. clsiServerId,
  727. }
  728. )
  729. })
  730. })
  731. describe('getFileFromClsi', function () {
  732. beforeEach(function (ctx) {
  733. ctx.clsiServerId = 'clsi-server-1'
  734. ctx.req.params = {
  735. Project_id: ctx.projectId,
  736. build_id: ctx.build_id,
  737. file: 'output.blg',
  738. }
  739. ctx.req.query = { clsiserverid: ctx.clsiServerId }
  740. ctx.req.session = {}
  741. ctx.req.method = 'GET'
  742. })
  743. describe('when the output.blg exists', function () {
  744. beforeEach(async function (ctx) {
  745. await ctx.CompileController.getFileFromClsi(ctx.req, ctx.res, ctx.next)
  746. })
  747. it('should open a request to the CLSI download host with compile limits', function (ctx) {
  748. ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
  749. `${ctx.settings.apis.clsi.downloadHost}/project/${ctx.projectId}/user/${ctx.user_id}/build/${ctx.build_id}/output/output.blg?clsiserverid=${ctx.clsiServerId}`
  750. )
  751. })
  752. it('should pass the response stream on to the client', function (ctx) {
  753. ctx.pipeline.should.have.been.calledWith(ctx.clsiStream, ctx.res)
  754. })
  755. })
  756. describe('when the output.blg traverses up', function () {
  757. beforeEach(async function (ctx) {
  758. ctx.req.params.file = '../output.blg'
  759. ctx.next = sinon.stub()
  760. await ctx.CompileController.getFileFromClsi(ctx.req, ctx.res, ctx.next)
  761. })
  762. it('should reject the request', function (ctx) {
  763. ctx.next.should.have.been.calledWithMatch({
  764. name: 'InvalidParamsError',
  765. zodError: asZodError({
  766. code: 'custom',
  767. path: ['params', 'file'],
  768. message: 'path traversal detected',
  769. }),
  770. })
  771. })
  772. it('should not open a request to CLSI', function (ctx) {
  773. ctx.fetchUtils.fetchStreamWithResponse.should.not.have.been.called
  774. })
  775. })
  776. describe('when the buildId traverses up', function () {
  777. beforeEach(async function (ctx) {
  778. ctx.req.params.build_id = '../..'
  779. ctx.next = sinon.stub()
  780. await ctx.CompileController.getFileFromClsi(ctx.req, ctx.res, ctx.next)
  781. })
  782. it('should reject the request', function (ctx) {
  783. ctx.next.should.have.been.calledWithMatch({
  784. name: 'InvalidParamsError',
  785. zodError: asZodError({
  786. origin: 'string',
  787. code: 'invalid_format',
  788. format: 'regex',
  789. pattern: '/^[0-9a-f]+-[0-9a-f]+$/',
  790. path: ['params', 'build_id'],
  791. message: 'invalid buildId',
  792. }),
  793. })
  794. })
  795. it('should not open a request to CLSI', function (ctx) {
  796. ctx.fetchUtils.fetchStreamWithResponse.should.not.have.been.called
  797. })
  798. })
  799. describe('when the output.blg does not exist', function () {
  800. beforeEach(async function (ctx) {
  801. ctx.editorId = '0e546f78-928e-4e8a-b5ea-3136ccf1dc53'
  802. ctx.req.query = {
  803. clsiserverid: ctx.clsiServerId,
  804. editorId: ctx.editorId,
  805. }
  806. ctx.clsiURL = `${ctx.settings.apis.clsi.downloadHost}/project/${ctx.projectId}/user/${ctx.user_id}/build/${ctx.build_id}/output/output.blg?clsiserverid=${ctx.clsiServerId}`
  807. ctx.fetchUtils.fetchStreamWithResponse.rejects(
  808. new RequestFailedError(
  809. ctx.clsiURL,
  810. { method: 'GET' },
  811. { status: 404 }
  812. )
  813. )
  814. await ctx.CompileController.getFileFromClsi(ctx.req, ctx.res, ctx.next)
  815. })
  816. it('should open a request to the CLSI', function (ctx) {
  817. ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
  818. ctx.clsiURL
  819. )
  820. })
  821. it('should fallback to clsi-cache', function (ctx) {
  822. ctx.ClsiCacheController._downloadFromCacheWithParams.should.have.been.calledWith(
  823. ctx.req,
  824. ctx.res,
  825. ctx.projectId,
  826. `${ctx.editorId}-${ctx.build_id}`,
  827. 'output.blg'
  828. )
  829. })
  830. })
  831. describe('when the output.stderr does not exist', function () {
  832. beforeEach(async function (ctx) {
  833. ctx.req.params.file = 'output.stderr'
  834. ctx.editorId = '0e546f78-928e-4e8a-b5ea-3136ccf1dc53'
  835. ctx.req.query = {
  836. clsiserverid: ctx.clsiServerId,
  837. editorId: ctx.editorId,
  838. }
  839. ctx.clsiURL = `${ctx.settings.apis.clsi.downloadHost}/project/${ctx.projectId}/user/${ctx.user_id}/build/${ctx.build_id}/output/output.stderr?clsiserverid=${ctx.clsiServerId}`
  840. ctx.fetchUtils.fetchStreamWithResponse.rejects(
  841. new RequestFailedError(
  842. ctx.clsiURL,
  843. { method: 'GET' },
  844. { status: 404 }
  845. )
  846. )
  847. await ctx.CompileController.getFileFromClsi(ctx.req, ctx.res, ctx.next)
  848. })
  849. it('should open a request to the CLSI', function (ctx) {
  850. ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
  851. ctx.clsiURL
  852. )
  853. })
  854. it('should not fallback to clsi-cache', function (ctx) {
  855. ctx.ClsiCacheController._downloadFromCacheWithParams.should.not.have
  856. .been.called
  857. expect(ctx.res.statusCode).to.equal(404)
  858. })
  859. })
  860. })
  861. describe('deleteAuxFiles', function () {
  862. beforeEach(async function (ctx) {
  863. ctx.CompileManager.promises.deleteAuxFiles = sinon.stub().resolves()
  864. ctx.req.params = { Project_id: ctx.projectId }
  865. ctx.req.query = { clsiserverid: 'node-1' }
  866. ctx.res.sendStatus = sinon.stub()
  867. await ctx.CompileController.deleteAuxFiles(ctx.req, ctx.res, ctx.next)
  868. })
  869. it('should proxy to the CLSI', function (ctx) {
  870. ctx.CompileManager.promises.deleteAuxFiles
  871. .calledWith(ctx.projectId, ctx.user_id, 'node-1')
  872. .should.equal(true)
  873. })
  874. it('should return a 200', function (ctx) {
  875. ctx.res.sendStatus.calledWith(200).should.equal(true)
  876. })
  877. })
  878. describe('compileAndDownloadPdf', function () {
  879. const clsiServerId = 'server-1'
  880. beforeEach(function (ctx) {
  881. ctx.req = {
  882. params: {
  883. project_id: ctx.projectId,
  884. },
  885. method: 'GET',
  886. }
  887. ctx.CompileManager.promises.compile.resolves({
  888. status: 'success',
  889. outputFiles: [{ path: 'output.pdf' }],
  890. clsiServerId,
  891. buildId: ctx.build_id,
  892. })
  893. ctx.res = {
  894. send: () => {},
  895. sendStatus: sinon.stub(),
  896. writeHead: sinon.stub(),
  897. setHeader: sinon.stub(),
  898. setTimeout: sinon.stub(),
  899. headersSent: false,
  900. }
  901. })
  902. it('should call compile in the compile manager', async function (ctx) {
  903. await ctx.CompileController.compileAndDownloadPdf(ctx.req, ctx.res)
  904. ctx.CompileManager.promises.compile
  905. .calledWith(ctx.projectId)
  906. .should.equal(true)
  907. })
  908. it('should proxy the PDF from the CLSI with the correct URL', async function (ctx) {
  909. await ctx.CompileController.compileAndDownloadPdf(ctx.req, ctx.res)
  910. ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
  911. `${ctx.settings.apis.clsi.downloadHost}/project/${ctx.projectId}/build/${ctx.build_id}/output/output.pdf?clsiserverid=${clsiServerId}`
  912. )
  913. })
  914. it('should not download anything on compilation failures', async function (ctx) {
  915. ctx.CompileManager.promises.compile.rejects(new Error('failed'))
  916. await ctx.CompileController.compileAndDownloadPdf(
  917. ctx.req,
  918. ctx.res,
  919. ctx.next
  920. )
  921. ctx.res.sendStatus.should.have.been.calledWith(500)
  922. ctx.fetchUtils.fetchStreamWithResponse.should.not.have.been.called
  923. })
  924. it('should not download anything on missing pdf', async function (ctx) {
  925. ctx.CompileManager.promises.compile.resolves({
  926. status: 'success',
  927. outputFiles: [],
  928. clsiServerId,
  929. buildId: ctx.build_id,
  930. })
  931. await ctx.CompileController.compileAndDownloadPdf(ctx.req, ctx.res)
  932. ctx.res.sendStatus.should.have.been.calledWith(500)
  933. ctx.fetchUtils.fetchStreamWithResponse.should.not.have.been.called
  934. })
  935. })
  936. describe('wordCount', function () {
  937. beforeEach(async function (ctx) {
  938. ctx.CompileManager.promises.wordCount = sinon
  939. .stub()
  940. .resolves({ content: 'body' })
  941. ctx.req.params = { Project_id: ctx.projectId }
  942. ctx.req.query = { clsiserverid: 'node-42' }
  943. ctx.res.json = sinon.stub()
  944. ctx.res.contentType = sinon.stub()
  945. await ctx.CompileController.wordCount(ctx.req, ctx.res, ctx.next)
  946. })
  947. it('should proxy to the CLSI', function (ctx) {
  948. ctx.CompileManager.promises.wordCount
  949. .calledWith(ctx.projectId, ctx.user_id, false, 'node-42')
  950. .should.equal(true)
  951. })
  952. it('should return a 200 and body', function (ctx) {
  953. ctx.res.json.calledWith({ content: 'body' }).should.equal(true)
  954. })
  955. })
  956. })