TpdsController.test.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. import { expect, vi } from 'vitest'
  2. import mongodb from 'mongodb-legacy'
  3. import sinon from 'sinon'
  4. import Errors from '../../../../app/src/Features/Errors/Errors.js'
  5. import MockResponse from '../helpers/MockResponse.mjs'
  6. import MockRequest from '../helpers/MockRequest.mjs'
  7. const ObjectId = mongodb.ObjectId
  8. const MODULE_PATH =
  9. '../../../../app/src/Features/ThirdPartyDataStore/TpdsController.mjs'
  10. describe('TpdsController', function () {
  11. beforeEach(async function (ctx) {
  12. ctx.metadata = {
  13. projectId: new ObjectId(),
  14. entityId: new ObjectId(),
  15. folderId: new ObjectId(),
  16. entityType: 'doc',
  17. rev: 2,
  18. }
  19. ctx.TpdsUpdateHandler = {
  20. promises: {
  21. newUpdate: sinon.stub().resolves(ctx.metadata),
  22. deleteUpdate: sinon.stub().resolves(ctx.metadata.entityId),
  23. createFolder: sinon.stub().resolves(),
  24. },
  25. }
  26. ctx.UpdateMerger = {
  27. promises: {
  28. mergeUpdate: sinon.stub().resolves(ctx.metadata),
  29. deleteUpdate: sinon.stub().resolves(ctx.metadata.entityId),
  30. },
  31. }
  32. ctx.NotificationsBuilder = {
  33. promises: {
  34. tpdsFileLimit: sinon.stub().returns({ create: sinon.stub() }),
  35. },
  36. }
  37. ctx.SessionManager = {
  38. getLoggedInUserId: sinon.stub().returns('user-id'),
  39. }
  40. ctx.TpdsQueueManager = {
  41. promises: {
  42. getQueues: sinon.stub().resolves('queues'),
  43. },
  44. }
  45. ctx.HttpErrorHandler = {
  46. conflict: sinon.stub(),
  47. }
  48. ctx.newProject = { _id: new ObjectId() }
  49. ctx.ProjectCreationHandler = {
  50. promises: { createBlankProject: sinon.stub().resolves(ctx.newProject) },
  51. }
  52. ctx.ProjectDetailsHandler = {
  53. promises: {
  54. generateUniqueName: sinon.stub().resolves('unique'),
  55. },
  56. }
  57. ctx.Metrics = {
  58. inc: sinon.stub(),
  59. }
  60. vi.doMock(
  61. '../../../../app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler',
  62. () => ({
  63. default: ctx.TpdsUpdateHandler,
  64. })
  65. )
  66. vi.doMock(
  67. '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger',
  68. () => ({
  69. default: ctx.UpdateMerger,
  70. })
  71. )
  72. vi.doMock(
  73. '../../../../app/src/Features/Notifications/NotificationsBuilder',
  74. () => ({
  75. default: ctx.NotificationsBuilder,
  76. })
  77. )
  78. vi.doMock(
  79. '../../../../app/src/Features/Authentication/SessionManager',
  80. () => ({
  81. default: ctx.SessionManager,
  82. })
  83. )
  84. vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({
  85. default: ctx.HttpErrorHandler,
  86. }))
  87. vi.doMock(
  88. '../../../../app/src/Features/ThirdPartyDataStore/TpdsQueueManager',
  89. () => ({
  90. default: ctx.TpdsQueueManager,
  91. })
  92. )
  93. vi.doMock(
  94. '../../../../app/src/Features/Project/ProjectCreationHandler',
  95. () => ({
  96. default: ctx.ProjectCreationHandler,
  97. })
  98. )
  99. vi.doMock(
  100. '../../../../app/src/Features/Project/ProjectDetailsHandler',
  101. () => ({
  102. default: ctx.ProjectDetailsHandler,
  103. })
  104. )
  105. vi.doMock('@overleaf/metrics', () => ({ default: ctx.Metrics }))
  106. ctx.TpdsController = (await import(MODULE_PATH)).default
  107. ctx.user_id = 'dsad29jlkjas'
  108. })
  109. describe('creating a project', function () {
  110. it('should yield the new projects id', async function (ctx) {
  111. await new Promise(resolve => {
  112. const res = new MockResponse(vi)
  113. const req = new MockRequest(vi)
  114. req.params.user_id = ctx.user_id
  115. req.body = { projectName: 'foo' }
  116. res.callback = err => {
  117. if (err) resolve(err)
  118. expect(res.body).to.equal(
  119. JSON.stringify({ projectId: ctx.newProject._id.toString() })
  120. )
  121. expect(
  122. ctx.ProjectDetailsHandler.promises.generateUniqueName
  123. ).to.have.been.calledWith(ctx.user_id, 'foo')
  124. expect(
  125. ctx.ProjectCreationHandler.promises.createBlankProject
  126. ).to.have.been.calledWith(
  127. ctx.user_id,
  128. 'unique',
  129. {},
  130. { skipCreatingInTPDS: true }
  131. )
  132. resolve()
  133. }
  134. ctx.TpdsController.createProject(req, res)
  135. })
  136. })
  137. })
  138. describe('getting an update', function () {
  139. beforeEach(function (ctx) {
  140. ctx.projectName = 'projectName'
  141. ctx.path = '/here.txt'
  142. ctx.req = {
  143. params: {
  144. 0: `${ctx.projectName}${ctx.path}`,
  145. user_id: ctx.user_id,
  146. project_id: '',
  147. },
  148. headers: {
  149. 'x-update-source': (ctx.source = 'dropbox'),
  150. },
  151. }
  152. })
  153. it('should process the update with the update receiver by name', async function (ctx) {
  154. await new Promise(resolve => {
  155. const res = {
  156. json: payload => {
  157. expect(payload).to.deep.equal({
  158. status: 'applied',
  159. projectId: ctx.metadata.projectId.toString(),
  160. entityId: ctx.metadata.entityId.toString(),
  161. folderId: ctx.metadata.folderId.toString(),
  162. entityType: ctx.metadata.entityType,
  163. rev: ctx.metadata.rev.toString(),
  164. })
  165. ctx.TpdsUpdateHandler.promises.newUpdate
  166. .calledWith(
  167. ctx.user_id,
  168. '', // projectId
  169. ctx.projectName,
  170. ctx.path,
  171. ctx.req,
  172. ctx.source
  173. )
  174. .should.equal(true)
  175. resolve()
  176. },
  177. }
  178. ctx.TpdsController.mergeUpdate(ctx.req, res)
  179. })
  180. })
  181. it('should indicate in the response when the update was rejected', async function (ctx) {
  182. await new Promise(resolve => {
  183. ctx.TpdsUpdateHandler.promises.newUpdate.resolves(null)
  184. const res = {
  185. json: payload => {
  186. expect(payload).to.deep.equal({ status: 'rejected' })
  187. resolve()
  188. },
  189. }
  190. ctx.TpdsController.mergeUpdate(ctx.req, res)
  191. })
  192. })
  193. it('should process the update with the update receiver by id', async function (ctx) {
  194. await new Promise(resolve => {
  195. const path = '/here.txt'
  196. const req = {
  197. pause() {},
  198. params: { 0: path, user_id: ctx.user_id, project_id: '123' },
  199. session: {
  200. destroy() {},
  201. },
  202. headers: {
  203. 'x-update-source': (ctx.source = 'dropbox'),
  204. },
  205. }
  206. const res = {
  207. json: () => {
  208. ctx.TpdsUpdateHandler.promises.newUpdate.should.have.been.calledWith(
  209. ctx.user_id,
  210. '123',
  211. '', // projectName
  212. '/here.txt',
  213. req,
  214. ctx.source
  215. )
  216. resolve()
  217. },
  218. }
  219. ctx.TpdsController.mergeUpdate(req, res)
  220. })
  221. })
  222. it('should return a 500 error when the update receiver fails', async function (ctx) {
  223. await new Promise(resolve => {
  224. ctx.TpdsUpdateHandler.promises.newUpdate.rejects(new Error())
  225. const res = {
  226. json: sinon.stub(),
  227. }
  228. ctx.TpdsController.mergeUpdate(ctx.req, res, err => {
  229. expect(err).to.exist
  230. expect(res.json).not.to.have.been.called
  231. resolve()
  232. })
  233. })
  234. })
  235. it('should return a 400 error when the project is too big', async function (ctx) {
  236. await new Promise(resolve => {
  237. ctx.TpdsUpdateHandler.promises.newUpdate.rejects({
  238. message: 'project_has_too_many_files',
  239. })
  240. const res = {
  241. sendStatus: status => {
  242. expect(status).to.equal(400)
  243. ctx.NotificationsBuilder.promises.tpdsFileLimit.should.have.been.calledWith(
  244. ctx.user_id
  245. )
  246. resolve()
  247. },
  248. }
  249. ctx.TpdsController.mergeUpdate(ctx.req, res)
  250. })
  251. })
  252. it('should return a 429 error when the update receiver fails due to too many requests error', async function (ctx) {
  253. await new Promise(resolve => {
  254. ctx.TpdsUpdateHandler.promises.newUpdate.rejects(
  255. new Errors.TooManyRequestsError('project on cooldown')
  256. )
  257. const res = {
  258. sendStatus: status => {
  259. expect(status).to.equal(429)
  260. resolve()
  261. },
  262. }
  263. ctx.TpdsController.mergeUpdate(ctx.req, res)
  264. })
  265. })
  266. })
  267. describe('getting a delete update', function () {
  268. it('should process the delete with the update receiver by name', async function (ctx) {
  269. await new Promise(resolve => {
  270. const path = '/projectName/here.txt'
  271. const req = {
  272. params: { 0: path, user_id: ctx.user_id, project_id: '' },
  273. session: {
  274. destroy() {},
  275. },
  276. headers: {
  277. 'x-update-source': (ctx.source = 'dropbox'),
  278. },
  279. }
  280. const res = {
  281. sendStatus: () => {
  282. ctx.TpdsUpdateHandler.promises.deleteUpdate
  283. .calledWith(
  284. ctx.user_id,
  285. '',
  286. 'projectName',
  287. '/here.txt',
  288. ctx.source
  289. )
  290. .should.equal(true)
  291. resolve()
  292. },
  293. }
  294. ctx.TpdsController.deleteUpdate(req, res)
  295. })
  296. })
  297. it('should process the delete with the update receiver by id', async function (ctx) {
  298. await new Promise(resolve => {
  299. const path = '/here.txt'
  300. const req = {
  301. params: { 0: path, user_id: ctx.user_id, project_id: '123' },
  302. session: {
  303. destroy() {},
  304. },
  305. headers: {
  306. 'x-update-source': (ctx.source = 'dropbox'),
  307. },
  308. }
  309. const res = {
  310. sendStatus: () => {
  311. ctx.TpdsUpdateHandler.promises.deleteUpdate.should.have.been.calledWith(
  312. ctx.user_id,
  313. '123',
  314. '', // projectName
  315. '/here.txt',
  316. ctx.source
  317. )
  318. resolve()
  319. },
  320. }
  321. ctx.TpdsController.deleteUpdate(req, res)
  322. })
  323. })
  324. })
  325. describe('updateFolder', function () {
  326. beforeEach(function (ctx) {
  327. ctx.req = {
  328. body: { userId: ctx.user_id, path: '/abc/def/ghi.txt' },
  329. }
  330. ctx.res = {
  331. json: sinon.stub(),
  332. }
  333. })
  334. it("creates a folder if it doesn't exist", async function (ctx) {
  335. await new Promise(resolve => {
  336. const metadata = {
  337. folderId: new ObjectId(),
  338. projectId: new ObjectId(),
  339. path: '/def/ghi.txt',
  340. parentFolderId: new ObjectId(),
  341. }
  342. ctx.TpdsUpdateHandler.promises.createFolder.resolves(metadata)
  343. ctx.res.json.callsFake(body => {
  344. expect(body).to.deep.equal({
  345. entityId: metadata.folderId.toString(),
  346. projectId: metadata.projectId.toString(),
  347. path: metadata.path,
  348. folderId: metadata.parentFolderId.toString(),
  349. })
  350. resolve()
  351. })
  352. ctx.TpdsController.updateFolder(ctx.req, ctx.res)
  353. })
  354. })
  355. it('supports top level folders', async function (ctx) {
  356. await new Promise(resolve => {
  357. const metadata = {
  358. folderId: new ObjectId(),
  359. projectId: new ObjectId(),
  360. path: '/',
  361. parentFolderId: null,
  362. }
  363. ctx.TpdsUpdateHandler.promises.createFolder.resolves(metadata)
  364. ctx.res.json.callsFake(body => {
  365. expect(body).to.deep.equal({
  366. entityId: metadata.folderId.toString(),
  367. projectId: metadata.projectId.toString(),
  368. path: metadata.path,
  369. folderId: null,
  370. })
  371. resolve()
  372. })
  373. ctx.TpdsController.updateFolder(ctx.req, ctx.res)
  374. })
  375. })
  376. it("returns a 409 if the folder couldn't be created", async function (ctx) {
  377. await new Promise(resolve => {
  378. ctx.TpdsUpdateHandler.promises.createFolder.resolves(null)
  379. ctx.HttpErrorHandler.conflict.callsFake((req, res) => {
  380. expect(req).to.equal(ctx.req)
  381. expect(res).to.equal(ctx.res)
  382. resolve()
  383. })
  384. ctx.TpdsController.updateFolder(ctx.req, ctx.res)
  385. })
  386. })
  387. })
  388. describe('parseParams', function () {
  389. it('should take the project name off the start and replace with slash', function (ctx) {
  390. const path = 'noSlashHere'
  391. const req = { params: { 0: path, user_id: ctx.user_id } }
  392. const result = ctx.TpdsController.parseParams(req)
  393. result.userId.should.equal(ctx.user_id)
  394. result.filePath.should.equal('/')
  395. result.projectName.should.equal(path)
  396. })
  397. it('should take the project name off the start and it with no slashes in', function (ctx) {
  398. const path = '/project/file.tex'
  399. const req = { params: { 0: path, user_id: ctx.user_id } }
  400. const result = ctx.TpdsController.parseParams(req)
  401. result.userId.should.equal(ctx.user_id)
  402. result.filePath.should.equal('/file.tex')
  403. result.projectName.should.equal('project')
  404. })
  405. it('should take the project name of and return a slash for the file path', function (ctx) {
  406. const path = '/project_name'
  407. const req = { params: { 0: path, user_id: ctx.user_id } }
  408. const result = ctx.TpdsController.parseParams(req)
  409. result.projectName.should.equal('project_name')
  410. result.filePath.should.equal('/')
  411. })
  412. })
  413. describe('updateProjectContents', function () {
  414. beforeEach(async function (ctx) {
  415. ctx.req = {
  416. params: {
  417. 0: (ctx.path = 'chapters/main.tex'),
  418. project_id: (ctx.project_id = 'project-id-123'),
  419. },
  420. session: {
  421. destroy: sinon.stub(),
  422. },
  423. headers: {
  424. 'x-update-source': (ctx.source = 'github'),
  425. },
  426. }
  427. ctx.res = {
  428. json: sinon.stub(),
  429. sendStatus: sinon.stub(),
  430. }
  431. await ctx.TpdsController.promises.updateProjectContents(ctx.req, ctx.res)
  432. })
  433. it('should merge the update', function (ctx) {
  434. ctx.UpdateMerger.promises.mergeUpdate.should.be.calledWith(
  435. null,
  436. ctx.project_id,
  437. `/${ctx.path}`,
  438. ctx.req,
  439. ctx.source
  440. )
  441. })
  442. it('should return a success', function (ctx) {
  443. ctx.res.json.should.be.calledWith({
  444. entityId: ctx.metadata.entityId.toString(),
  445. rev: ctx.metadata.rev,
  446. })
  447. })
  448. })
  449. describe('deleteProjectContents', function () {
  450. beforeEach(async function (ctx) {
  451. ctx.req = {
  452. params: {
  453. 0: (ctx.path = 'chapters/main.tex'),
  454. project_id: (ctx.project_id = 'project-id-123'),
  455. },
  456. session: {
  457. destroy: sinon.stub(),
  458. },
  459. headers: {
  460. 'x-update-source': (ctx.source = 'github'),
  461. },
  462. }
  463. ctx.res = {
  464. sendStatus: sinon.stub(),
  465. json: sinon.stub(),
  466. }
  467. await ctx.TpdsController.promises.deleteProjectContents(ctx.req, ctx.res)
  468. })
  469. it('should delete the file', function (ctx) {
  470. ctx.UpdateMerger.promises.deleteUpdate.should.be.calledWith(
  471. null,
  472. ctx.project_id,
  473. `/${ctx.path}`,
  474. ctx.source
  475. )
  476. })
  477. it('should return a success', function (ctx) {
  478. ctx.res.json.should.be.calledWith({
  479. entityId: ctx.metadata.entityId,
  480. })
  481. })
  482. })
  483. describe('getQueues', function () {
  484. beforeEach(function (ctx) {
  485. ctx.req = {}
  486. ctx.res = { json: sinon.stub() }
  487. ctx.next = sinon.stub()
  488. })
  489. describe('success', function () {
  490. beforeEach(async function (ctx) {
  491. await new Promise(resolve => {
  492. ctx.res.json.callsFake(() => {
  493. resolve()
  494. })
  495. ctx.TpdsController.getQueues(ctx.req, ctx.res, ctx.next)
  496. })
  497. })
  498. it('should use userId from session', function (ctx) {
  499. ctx.SessionManager.getLoggedInUserId.should.have.been.calledOnce
  500. ctx.TpdsQueueManager.promises.getQueues.should.have.been.calledWith(
  501. 'user-id'
  502. )
  503. })
  504. it('should call json with response', function (ctx) {
  505. ctx.res.json.should.have.been.calledWith('queues')
  506. ctx.next.should.not.have.been.called
  507. })
  508. })
  509. describe('error', function () {
  510. beforeEach(async function (ctx) {
  511. await new Promise(resolve => {
  512. ctx.err = new Error()
  513. ctx.TpdsQueueManager.promises.getQueues = sinon
  514. .stub()
  515. .rejects(ctx.err)
  516. ctx.next.callsFake(() => {
  517. resolve()
  518. })
  519. ctx.TpdsController.getQueues(ctx.req, ctx.res, ctx.next)
  520. })
  521. })
  522. it('should call next with error', function (ctx) {
  523. ctx.res.json.should.not.have.been.called
  524. ctx.next.should.have.been.calledWith(ctx.err)
  525. })
  526. })
  527. })
  528. })