HttpController.test.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. import sinon from 'sinon'
  2. import { assert, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import path from 'node:path'
  4. import { ObjectId } from 'mongodb-legacy'
  5. import Errors from '../../../app/js/Errors.js'
  6. const modulePath = path.join(
  7. import.meta.dirname,
  8. '../../../app/js/HttpController'
  9. )
  10. describe('HttpController', () => {
  11. beforeEach(async ctx => {
  12. const settings = {
  13. max_doc_length: 2 * 1024 * 1024,
  14. }
  15. ctx.DocArchiveManager = {
  16. unArchiveAllDocs: sinon.stub().returns(),
  17. }
  18. ctx.DocManager = {}
  19. vi.doMock('../../../app/js/DocManager', () => ({
  20. default: ctx.DocManager,
  21. }))
  22. vi.doMock('../../../app/js/DocArchiveManager', () => ({
  23. default: ctx.DocArchiveManager,
  24. }))
  25. vi.doMock('@overleaf/settings', () => ({
  26. default: settings,
  27. }))
  28. vi.doMock('../../../app/js/HealthChecker', () => ({
  29. default: {},
  30. }))
  31. vi.doMock('../../../app/js/Errors', () => ({
  32. default: Errors,
  33. }))
  34. ctx.HttpController = (await import(modulePath)).default
  35. ctx.res = {
  36. send: sinon.stub(),
  37. sendStatus: sinon.stub(),
  38. json: sinon.stub(),
  39. setHeader: sinon.stub(),
  40. }
  41. ctx.res.status = sinon.stub().returns(ctx.res)
  42. ctx.req = { query: {} }
  43. ctx.next = sinon.stub()
  44. ctx.projectId = 'mock-project-id'
  45. ctx.docId = 'mock-doc-id'
  46. ctx.doc = {
  47. _id: ctx.docId,
  48. lines: ['mock', 'lines', ' here', '', '', ' spaces '],
  49. version: 42,
  50. rev: 5,
  51. }
  52. ctx.deletedDoc = {
  53. deleted: true,
  54. _id: ctx.docId,
  55. lines: ['mock', 'lines', ' here', '', '', ' spaces '],
  56. version: 42,
  57. rev: 5,
  58. }
  59. })
  60. describe('getDoc', () => {
  61. describe('without deleted docs', () => {
  62. beforeEach(async ctx => {
  63. ctx.req.params = {
  64. project_id: ctx.projectId,
  65. doc_id: ctx.docId,
  66. }
  67. ctx.DocManager.getFullDoc = sinon.stub().resolves(ctx.doc)
  68. await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next)
  69. })
  70. it('should get the document with the version (including deleted)', ctx => {
  71. ctx.DocManager.getFullDoc
  72. .calledWith(ctx.projectId, ctx.docId)
  73. .should.equal(true)
  74. })
  75. it('should return the doc as JSON', ctx => {
  76. ctx.res.json
  77. .calledWith({
  78. _id: ctx.docId,
  79. lines: ctx.doc.lines,
  80. rev: ctx.doc.rev,
  81. version: ctx.doc.version,
  82. })
  83. .should.equal(true)
  84. })
  85. })
  86. describe('which is deleted', () => {
  87. beforeEach(ctx => {
  88. ctx.req.params = {
  89. project_id: ctx.projectId,
  90. doc_id: ctx.docId,
  91. }
  92. ctx.DocManager.getFullDoc = sinon.stub().resolves(ctx.deletedDoc)
  93. })
  94. it('should get the doc from the doc manager', async ctx => {
  95. await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next)
  96. ctx.DocManager.getFullDoc
  97. .calledWith(ctx.projectId, ctx.docId)
  98. .should.equal(true)
  99. })
  100. it('should return 404 if the query string delete is not set ', async ctx => {
  101. await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next)
  102. ctx.res.sendStatus.calledWith(404).should.equal(true)
  103. })
  104. it('should return the doc as JSON if include_deleted is set to true', async ctx => {
  105. ctx.req.query.include_deleted = 'true'
  106. await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next)
  107. ctx.res.json
  108. .calledWith({
  109. _id: ctx.docId,
  110. lines: ctx.doc.lines,
  111. rev: ctx.doc.rev,
  112. deleted: true,
  113. version: ctx.doc.version,
  114. })
  115. .should.equal(true)
  116. })
  117. })
  118. })
  119. describe('getRawDoc', () => {
  120. beforeEach(async ctx => {
  121. ctx.req.params = {
  122. project_id: ctx.projectId,
  123. doc_id: ctx.docId,
  124. }
  125. ctx.DocManager.getDocLines = sinon
  126. .stub()
  127. .resolves(ctx.doc.lines.join('\n'))
  128. await ctx.HttpController.getRawDoc(ctx.req, ctx.res, ctx.next)
  129. })
  130. it('should get the document without the version', ctx => {
  131. ctx.DocManager.getDocLines
  132. .calledWith(ctx.projectId, ctx.docId)
  133. .should.equal(true)
  134. })
  135. it('should set the content type header', ctx => {
  136. ctx.res.setHeader
  137. .calledWith('content-type', 'text/plain')
  138. .should.equal(true)
  139. })
  140. it('should send the raw version of the doc', ctx => {
  141. assert.deepEqual(
  142. ctx.res.send.args[0][0],
  143. `${ctx.doc.lines[0]}\n${ctx.doc.lines[1]}\n${ctx.doc.lines[2]}\n${ctx.doc.lines[3]}\n${ctx.doc.lines[4]}\n${ctx.doc.lines[5]}`
  144. )
  145. })
  146. })
  147. describe('getAllDocs', () => {
  148. describe('normally', () => {
  149. beforeEach(async ctx => {
  150. ctx.req.params = { project_id: ctx.projectId }
  151. ctx.docs = [
  152. {
  153. _id: new ObjectId(),
  154. lines: ['mock', 'lines', 'one'],
  155. rev: 2,
  156. },
  157. {
  158. _id: new ObjectId(),
  159. lines: ['mock', 'lines', 'two'],
  160. rev: 4,
  161. },
  162. ]
  163. ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs)
  164. await ctx.HttpController.getAllDocs(ctx.req, ctx.res, ctx.next)
  165. })
  166. it('should get all the (non-deleted) docs', ctx => {
  167. ctx.DocManager.getAllNonDeletedDocs
  168. .calledWith(ctx.projectId, { lines: true, rev: true })
  169. .should.equal(true)
  170. })
  171. it('should return the doc as JSON', ctx => {
  172. ctx.res.json
  173. .calledWith([
  174. {
  175. _id: ctx.docs[0]._id.toString(),
  176. lines: ctx.docs[0].lines,
  177. rev: ctx.docs[0].rev,
  178. },
  179. {
  180. _id: ctx.docs[1]._id.toString(),
  181. lines: ctx.docs[1].lines,
  182. rev: ctx.docs[1].rev,
  183. },
  184. ])
  185. .should.equal(true)
  186. })
  187. })
  188. describe('with null lines', () => {
  189. beforeEach(async ctx => {
  190. ctx.req.params = { project_id: ctx.projectId }
  191. ctx.docs = [
  192. {
  193. _id: new ObjectId(),
  194. lines: null,
  195. rev: 2,
  196. },
  197. {
  198. _id: new ObjectId(),
  199. lines: ['mock', 'lines', 'two'],
  200. rev: 4,
  201. },
  202. ]
  203. ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs)
  204. await ctx.HttpController.getAllDocs(ctx.req, ctx.res, ctx.next)
  205. })
  206. it('should return the doc with fallback lines', ctx => {
  207. ctx.res.json
  208. .calledWith([
  209. {
  210. _id: ctx.docs[0]._id.toString(),
  211. lines: [],
  212. rev: ctx.docs[0].rev,
  213. },
  214. {
  215. _id: ctx.docs[1]._id.toString(),
  216. lines: ctx.docs[1].lines,
  217. rev: ctx.docs[1].rev,
  218. },
  219. ])
  220. .should.equal(true)
  221. })
  222. })
  223. describe('with a null doc', () => {
  224. beforeEach(async ctx => {
  225. ctx.req.params = { project_id: ctx.projectId }
  226. ctx.docs = [
  227. {
  228. _id: new ObjectId(),
  229. lines: ['mock', 'lines', 'one'],
  230. rev: 2,
  231. },
  232. null,
  233. {
  234. _id: new ObjectId(),
  235. lines: ['mock', 'lines', 'two'],
  236. rev: 4,
  237. },
  238. ]
  239. ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs)
  240. await ctx.HttpController.getAllDocs(ctx.req, ctx.res, ctx.next)
  241. })
  242. it('should return the non null docs as JSON', ctx => {
  243. ctx.res.json
  244. .calledWith([
  245. {
  246. _id: ctx.docs[0]._id.toString(),
  247. lines: ctx.docs[0].lines,
  248. rev: ctx.docs[0].rev,
  249. },
  250. {
  251. _id: ctx.docs[2]._id.toString(),
  252. lines: ctx.docs[2].lines,
  253. rev: ctx.docs[2].rev,
  254. },
  255. ])
  256. .should.equal(true)
  257. })
  258. it('should log out an error', ctx => {
  259. ctx.logger.error
  260. .calledWith(
  261. {
  262. err: sinon.match.has('message', 'null doc'),
  263. projectId: ctx.projectId,
  264. },
  265. 'encountered null doc'
  266. )
  267. .should.equal(true)
  268. })
  269. })
  270. })
  271. describe('getAllRanges', () => {
  272. describe('normally', () => {
  273. beforeEach(async ctx => {
  274. ctx.req.params = { project_id: ctx.projectId }
  275. ctx.docs = [
  276. {
  277. _id: new ObjectId(),
  278. ranges: { mock_ranges: 'one' },
  279. },
  280. {
  281. _id: new ObjectId(),
  282. ranges: { mock_ranges: 'two' },
  283. },
  284. ]
  285. ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs)
  286. await ctx.HttpController.getAllRanges(ctx.req, ctx.res, ctx.next)
  287. })
  288. it('should get all the (non-deleted) doc ranges', ctx => {
  289. ctx.DocManager.getAllNonDeletedDocs
  290. .calledWith(ctx.projectId, { ranges: true })
  291. .should.equal(true)
  292. })
  293. it('should return the doc as JSON', ctx => {
  294. ctx.res.json
  295. .calledWith([
  296. {
  297. _id: ctx.docs[0]._id.toString(),
  298. ranges: ctx.docs[0].ranges,
  299. },
  300. {
  301. _id: ctx.docs[1]._id.toString(),
  302. ranges: ctx.docs[1].ranges,
  303. },
  304. ])
  305. .should.equal(true)
  306. })
  307. })
  308. })
  309. describe('updateDoc', () => {
  310. beforeEach(ctx => {
  311. ctx.req.params = {
  312. project_id: ctx.projectId,
  313. doc_id: ctx.docId,
  314. }
  315. })
  316. describe('when the doc lines exist and were updated', () => {
  317. beforeEach(async ctx => {
  318. ctx.req.body = {
  319. lines: (ctx.lines = ['hello', 'world']),
  320. version: (ctx.version = 42),
  321. ranges: (ctx.ranges = { changes: 'mock' }),
  322. }
  323. ctx.rev = 5
  324. ctx.DocManager.updateDoc = sinon
  325. .stub()
  326. .resolves({ modified: true, rev: ctx.rev })
  327. await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
  328. })
  329. it('should update the document', ctx => {
  330. ctx.DocManager.updateDoc
  331. .calledWith(
  332. ctx.projectId,
  333. ctx.docId,
  334. ctx.lines,
  335. ctx.version,
  336. ctx.ranges
  337. )
  338. .should.equal(true)
  339. })
  340. it('should return a modified status', ctx => {
  341. ctx.res.json
  342. .calledWith({ modified: true, rev: ctx.rev })
  343. .should.equal(true)
  344. })
  345. })
  346. describe('when the doc lines exist and were not updated', () => {
  347. beforeEach(async ctx => {
  348. ctx.req.body = {
  349. lines: (ctx.lines = ['hello', 'world']),
  350. version: (ctx.version = 42),
  351. ranges: {},
  352. }
  353. ctx.rev = 5
  354. ctx.DocManager.updateDoc = sinon
  355. .stub()
  356. .resolves({ modified: false, rev: ctx.rev })
  357. await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
  358. })
  359. it('should return a modified status', ctx => {
  360. ctx.res.json
  361. .calledWith({ modified: false, rev: ctx.rev })
  362. .should.equal(true)
  363. })
  364. })
  365. describe('when the doc lines are not provided', () => {
  366. beforeEach(async ctx => {
  367. ctx.req.body = { version: 42, ranges: {} }
  368. ctx.DocManager.updateDoc = sinon
  369. .stub()
  370. .resolves({ modified: false, rev: 0 })
  371. await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
  372. })
  373. it('should not update the document', ctx => {
  374. ctx.DocManager.updateDoc.called.should.equal(false)
  375. })
  376. it('should return a 400 (bad request) response', ctx => {
  377. ctx.res.sendStatus.calledWith(400).should.equal(true)
  378. })
  379. })
  380. describe('when the doc version are not provided', () => {
  381. beforeEach(async ctx => {
  382. ctx.req.body = { version: 42, lines: ['hello world'] }
  383. ctx.DocManager.updateDoc = sinon
  384. .stub()
  385. .resolves({ modified: false, rev: 0 })
  386. await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
  387. })
  388. it('should not update the document', ctx => {
  389. ctx.DocManager.updateDoc.called.should.equal(false)
  390. })
  391. it('should return a 400 (bad request) response', ctx => {
  392. ctx.res.sendStatus.calledWith(400).should.equal(true)
  393. })
  394. })
  395. describe('when the doc ranges is not provided', () => {
  396. beforeEach(async ctx => {
  397. ctx.req.body = { lines: ['foo'], version: 42 }
  398. ctx.DocManager.updateDoc = sinon
  399. .stub()
  400. .resolves({ modified: false, rev: 0 })
  401. await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
  402. })
  403. it('should not update the document', ctx => {
  404. ctx.DocManager.updateDoc.called.should.equal(false)
  405. })
  406. it('should return a 400 (bad request) response', ctx => {
  407. ctx.res.sendStatus.calledWith(400).should.equal(true)
  408. })
  409. })
  410. describe('when the doc body is too large', () => {
  411. beforeEach(async ctx => {
  412. ctx.req.body = {
  413. lines: (ctx.lines = Array(2049).fill('a'.repeat(1024))),
  414. version: (ctx.version = 42),
  415. ranges: (ctx.ranges = { changes: 'mock' }),
  416. }
  417. ctx.DocManager.updateDoc = sinon
  418. .stub()
  419. .resolves({ modified: false, rev: 0 })
  420. await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
  421. })
  422. it('should not update the document', ctx => {
  423. ctx.DocManager.updateDoc.called.should.equal(false)
  424. })
  425. it('should return a 413 (too large) response', ctx => {
  426. sinon.assert.calledWith(ctx.res.status, 413)
  427. })
  428. it('should report that the document body is too large', ctx => {
  429. sinon.assert.calledWith(ctx.res.send, 'document body too large')
  430. })
  431. })
  432. })
  433. describe('patchDoc', () => {
  434. beforeEach(async ctx => {
  435. ctx.req.params = {
  436. project_id: ctx.projectId,
  437. doc_id: ctx.docId,
  438. }
  439. ctx.req.body = {
  440. deleted: true,
  441. deletedAt: '2026-06-15T00:00:00Z',
  442. name: 'foo.tex',
  443. }
  444. ctx.DocManager.patchDoc = sinon.stub().resolves()
  445. await ctx.HttpController.patchDoc(ctx.req, ctx.res, ctx.next)
  446. })
  447. it('should delete the document', ctx => {
  448. expect(ctx.DocManager.patchDoc).to.have.been.calledWith(
  449. ctx.projectId,
  450. ctx.docId,
  451. {
  452. deleted: true,
  453. deletedAt: new Date('2026-06-15T00:00:00Z'),
  454. name: 'foo.tex',
  455. }
  456. )
  457. })
  458. it('should return a 204 (No Content)', ctx => {
  459. expect(ctx.res.sendStatus).to.have.been.calledWith(204)
  460. })
  461. describe('with an invalid payload', () => {
  462. beforeEach(async ctx => {
  463. ctx.req.body = { cannot: 'happen' }
  464. ctx.DocManager.patchDoc = sinon.stub().resolves()
  465. await ctx.HttpController.patchDoc(ctx.req, ctx.res, ctx.next)
  466. })
  467. it('should pass a validation error to next', ctx => {
  468. expect(ctx.next).to.have.been.calledOnce
  469. expect(ctx.next.firstCall.args[0].name).to.equal('InvalidRequestError')
  470. })
  471. it('should not patch the document', ctx => {
  472. expect(ctx.DocManager.patchDoc).not.to.have.been.called
  473. })
  474. })
  475. })
  476. describe('archiveAllDocs', () => {
  477. beforeEach(async ctx => {
  478. ctx.req.params = { project_id: ctx.projectId }
  479. ctx.DocArchiveManager.archiveAllDocs = sinon.stub().resolves()
  480. await ctx.HttpController.archiveAllDocs(ctx.req, ctx.res, ctx.next)
  481. })
  482. it('should archive the project', ctx => {
  483. ctx.DocArchiveManager.archiveAllDocs
  484. .calledWith(ctx.projectId)
  485. .should.equal(true)
  486. })
  487. it('should return a 204 (No Content)', ctx => {
  488. ctx.res.sendStatus.calledWith(204).should.equal(true)
  489. })
  490. })
  491. describe('unArchiveAllDocs', () => {
  492. beforeEach(ctx => {
  493. ctx.req.params = { project_id: ctx.projectId }
  494. })
  495. describe('on success', () => {
  496. beforeEach(async ctx => {
  497. await ctx.HttpController.unArchiveAllDocs(ctx.req, ctx.res, ctx.next)
  498. })
  499. it('returns a 200', ctx => {
  500. expect(ctx.res.sendStatus).to.have.been.calledWith(200)
  501. })
  502. })
  503. describe("when the archived rev doesn't match", () => {
  504. beforeEach(async ctx => {
  505. ctx.DocArchiveManager.unArchiveAllDocs.rejects(
  506. new Errors.DocRevValueError('bad rev')
  507. )
  508. await ctx.HttpController.unArchiveAllDocs(ctx.req, ctx.res, ctx.next)
  509. })
  510. it('returns a 409', ctx => {
  511. expect(ctx.res.sendStatus).to.have.been.calledWith(409)
  512. })
  513. })
  514. })
  515. describe('destroyProject', () => {
  516. beforeEach(async ctx => {
  517. ctx.req.params = { project_id: ctx.projectId }
  518. ctx.DocArchiveManager.destroyProject = sinon.stub().resolves()
  519. await ctx.HttpController.destroyProject(ctx.req, ctx.res, ctx.next)
  520. })
  521. it('should destroy the docs', ctx => {
  522. sinon.assert.calledWith(
  523. ctx.DocArchiveManager.destroyProject,
  524. ctx.projectId
  525. )
  526. })
  527. it('should return 204', ctx => {
  528. sinon.assert.calledWith(ctx.res.sendStatus, 204)
  529. })
  530. })
  531. })