EditorHttpControllerTests.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. const SandboxedModule = require('sandboxed-module')
  2. const sinon = require('sinon')
  3. const { expect } = require('chai')
  4. const { ObjectId } = require('mongodb')
  5. const Errors = require('../../../../app/src/Features/Errors/Errors')
  6. const HttpErrors = require('@overleaf/o-error/http')
  7. const MODULE_PATH = '../../../../app/src/Features/Editor/EditorHttpController'
  8. describe('EditorHttpController', function() {
  9. beforeEach(function() {
  10. this.ownerId = new ObjectId()
  11. this.project = {
  12. _id: new ObjectId(),
  13. owner_ref: this.ownerId
  14. }
  15. this.user = {
  16. _id: new ObjectId(),
  17. projects: {}
  18. }
  19. this.projectView = {
  20. _id: this.project._id,
  21. owner: {
  22. _id: 'owner',
  23. email: 'owner@example.com',
  24. other_property: true
  25. }
  26. }
  27. this.reducedProjectView = {
  28. _id: this.projectView._id,
  29. owner: { _id: this.projectView.owner._id }
  30. }
  31. this.doc = { _id: new ObjectId(), name: 'excellent-original-idea.tex' }
  32. this.file = { _id: new ObjectId() }
  33. this.folder = { _id: new ObjectId() }
  34. this.parentFolderId = 'mock-folder-id'
  35. this.req = { i18n: { translate: string => string } }
  36. this.res = {
  37. send: sinon.stub().returns(this.res),
  38. status: sinon.stub().returns(this.res),
  39. sendStatus: sinon.stub().returns(this.res),
  40. json: sinon.stub().returns(this.res)
  41. }
  42. this.next = sinon.stub()
  43. this.token = null
  44. this.docLines = ['hello', 'overleaf']
  45. this.AuthorizationManager = {
  46. isRestrictedUser: sinon.stub().returns(false),
  47. promises: {
  48. getPrivilegeLevelForProject: sinon.stub().resolves('owner')
  49. }
  50. }
  51. this.CollaboratorsGetter = {
  52. promises: {
  53. getInvitedMembersWithPrivilegeLevels: sinon
  54. .stub()
  55. .resolves(['members', 'mock'])
  56. }
  57. }
  58. this.CollaboratorsHandler = {
  59. promises: {
  60. userIsTokenMember: sinon.stub().resolves(false)
  61. }
  62. }
  63. this.CollaboratorsInviteHandler = {
  64. promises: {
  65. getAllInvites: sinon.stub().resolves([
  66. {
  67. _id: 'invite_one',
  68. email: 'user-one@example.com',
  69. privileges: 'readOnly',
  70. projectId: this.project._id
  71. },
  72. {
  73. _id: 'invite_two',
  74. email: 'user-two@example.com',
  75. privileges: 'readOnly',
  76. projectId: this.project._id
  77. }
  78. ])
  79. }
  80. }
  81. this.EditorController = {
  82. promises: {
  83. addDoc: sinon.stub().resolves(this.doc),
  84. addFile: sinon.stub().resolves(this.file),
  85. addFolder: sinon.stub().resolves(this.folder),
  86. renameEntity: sinon.stub().resolves(),
  87. moveEntity: sinon.stub().resolves(),
  88. deleteEntity: sinon.stub().resolves()
  89. }
  90. }
  91. this.ProjectDeleter = {
  92. promises: {
  93. unmarkAsDeletedByExternalSource: sinon.stub().resolves()
  94. }
  95. }
  96. this.ProjectGetter = {
  97. promises: {
  98. getProjectWithoutDocLines: sinon.stub().resolves(this.project)
  99. }
  100. }
  101. this.ProjectEditorHandler = {
  102. buildProjectModelView: sinon.stub().returns(this.projectView)
  103. }
  104. this.logger = {
  105. log: sinon.stub(),
  106. error: sinon.stub()
  107. }
  108. this.Metrics = { inc: sinon.stub() }
  109. this.TokenAccessHandler = {
  110. getRequestToken: sinon.stub().returns(this.token),
  111. protectTokens: sinon.stub()
  112. }
  113. this.AuthenticationController = {
  114. getLoggedInUserId: sinon.stub().returns(this.user._id)
  115. }
  116. this.ProjectEntityUpdateHandler = {
  117. promises: {
  118. convertDocToFile: sinon.stub().resolves(this.file)
  119. }
  120. }
  121. this.EditorHttpController = SandboxedModule.require(MODULE_PATH, {
  122. globals: {
  123. console: console
  124. },
  125. requires: {
  126. '../Project/ProjectDeleter': this.ProjectDeleter,
  127. '../Project/ProjectGetter': this.ProjectGetter,
  128. '../Authorization/AuthorizationManager': this.AuthorizationManager,
  129. '../Project/ProjectEditorHandler': this.ProjectEditorHandler,
  130. 'logger-sharelatex': this.logger,
  131. './EditorController': this.EditorController,
  132. 'metrics-sharelatex': this.Metrics,
  133. '../Collaborators/CollaboratorsGetter': this.CollaboratorsGetter,
  134. '../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler,
  135. '../Collaborators/CollaboratorsInviteHandler': this
  136. .CollaboratorsInviteHandler,
  137. '../TokenAccess/TokenAccessHandler': this.TokenAccessHandler,
  138. '../Authentication/AuthenticationController': this
  139. .AuthenticationController,
  140. '../../infrastructure/FileWriter': this.FileWriter,
  141. '../Project/ProjectEntityUpdateHandler': this
  142. .ProjectEntityUpdateHandler,
  143. '../Errors/Errors': Errors,
  144. '@overleaf/o-error/http': HttpErrors
  145. }
  146. })
  147. })
  148. describe('joinProject', function() {
  149. beforeEach(function() {
  150. this.req.params = { Project_id: this.project._id }
  151. this.req.query = { user_id: this.user._id }
  152. })
  153. describe('successfully', function() {
  154. beforeEach(function(done) {
  155. this.res.json.callsFake(() => done())
  156. this.EditorHttpController.joinProject(this.req, this.res)
  157. })
  158. it('should return the project and privilege level', function() {
  159. expect(this.res.json).to.have.been.calledWith({
  160. project: this.projectView,
  161. privilegeLevel: 'owner',
  162. isRestrictedUser: false
  163. })
  164. })
  165. it('should not try to unmark the project as deleted', function() {
  166. expect(this.ProjectDeleter.promises.unmarkAsDeletedByExternalSource).not
  167. .to.have.been.called
  168. })
  169. it('should send an inc metric', function() {
  170. expect(this.Metrics.inc).to.have.been.calledWith('editor.join-project')
  171. })
  172. })
  173. describe('when the project is marked as deleted', function() {
  174. beforeEach(function(done) {
  175. this.projectView.deletedByExternalDataSource = true
  176. this.res.json.callsFake(() => done())
  177. this.EditorHttpController.joinProject(this.req, this.res)
  178. })
  179. it('should unmark the project as deleted', function() {
  180. expect(
  181. this.ProjectDeleter.promises.unmarkAsDeletedByExternalSource
  182. ).to.have.been.calledWith(this.project._id)
  183. })
  184. })
  185. describe('with a restricted user', function() {
  186. beforeEach(function(done) {
  187. this.AuthorizationManager.isRestrictedUser.returns(true)
  188. this.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves(
  189. 'readOnly'
  190. )
  191. this.res.json.callsFake(() => done())
  192. this.EditorHttpController.joinProject(this.req, this.res)
  193. })
  194. it('should mark the user as restricted, and hide details of owner', function() {
  195. expect(this.res.json).to.have.been.calledWith({
  196. project: this.reducedProjectView,
  197. privilegeLevel: 'readOnly',
  198. isRestrictedUser: true
  199. })
  200. })
  201. })
  202. describe('when not authorized', function() {
  203. beforeEach(function(done) {
  204. this.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves(
  205. null
  206. )
  207. this.res.sendStatus.callsFake(() => done())
  208. this.EditorHttpController.joinProject(this.req, this.res)
  209. })
  210. it('should send a 403 response', function() {
  211. expect(this.res.sendStatus).to.have.been.calledWith(403)
  212. })
  213. })
  214. describe('with an anonymous user', function() {
  215. beforeEach(function(done) {
  216. this.req.query = { user_id: 'anonymous-user' }
  217. this.res.json.callsFake(() => done())
  218. this.AuthorizationManager.isRestrictedUser
  219. .withArgs(null, 'readOnly', false)
  220. .returns(true)
  221. this.AuthorizationManager.promises.getPrivilegeLevelForProject
  222. .withArgs(null, this.project._id, this.token)
  223. .resolves('readOnly')
  224. this.EditorHttpController.joinProject(this.req, this.res)
  225. })
  226. it('should mark the user as restricted', function() {
  227. expect(this.res.json).to.have.been.calledWith({
  228. project: this.reducedProjectView,
  229. privilegeLevel: 'readOnly',
  230. isRestrictedUser: true
  231. })
  232. })
  233. })
  234. describe('when project is not found', function() {
  235. beforeEach(function(done) {
  236. this.ProjectGetter.promises.getProjectWithoutDocLines.resolves(null)
  237. this.next.callsFake(() => done())
  238. this.EditorHttpController.joinProject(this.req, this.res, this.next)
  239. })
  240. it('should handle return not found error', function() {
  241. expect(this.next).to.have.been.calledWith(
  242. sinon.match.instanceOf(Errors.NotFoundError)
  243. )
  244. })
  245. })
  246. })
  247. describe('addDoc', function() {
  248. beforeEach(function() {
  249. this.req.params = { Project_id: this.project._id }
  250. this.req.body = {
  251. name: (this.name = 'doc-name'),
  252. parent_folder_id: this.parentFolderId
  253. }
  254. })
  255. describe('successfully', function() {
  256. beforeEach(function(done) {
  257. this.res.json.callsFake(() => done())
  258. this.EditorHttpController.addDoc(this.req, this.res)
  259. })
  260. it('should call EditorController.addDoc', function() {
  261. expect(this.EditorController.promises.addDoc).to.have.been.calledWith(
  262. this.project._id,
  263. this.parentFolderId,
  264. this.name,
  265. [],
  266. 'editor',
  267. this.user._id
  268. )
  269. })
  270. it('should send the doc back as JSON', function() {
  271. expect(this.res.json).to.have.been.calledWith(this.doc)
  272. })
  273. })
  274. describe('unsuccesfully', function() {
  275. it('handle name too short', function(done) {
  276. this.req.body.name = ''
  277. this.res.sendStatus.callsFake(status => {
  278. expect(status).to.equal(400)
  279. done()
  280. })
  281. this.EditorHttpController.addDoc(this.req, this.res)
  282. })
  283. it('handle too many files', function(done) {
  284. this.EditorController.promises.addDoc.rejects(
  285. new Error('project_has_too_many_files')
  286. )
  287. this.res.json.callsFake(payload => {
  288. expect(payload).to.equal('project_has_too_many_files')
  289. expect(this.res.status).to.have.been.calledWith(400)
  290. done()
  291. })
  292. this.res.status.returns(this.res)
  293. this.EditorHttpController.addDoc(this.req, this.res)
  294. })
  295. })
  296. })
  297. describe('addFolder', function() {
  298. beforeEach(function() {
  299. this.folderName = 'folder-name'
  300. this.req.params = { Project_id: this.project._id }
  301. this.req.body = {
  302. name: this.folderName,
  303. parent_folder_id: this.parentFolderId
  304. }
  305. })
  306. describe('successfully', function() {
  307. beforeEach(function(done) {
  308. this.res.json.callsFake(() => done())
  309. this.EditorHttpController.addFolder(this.req, this.res)
  310. })
  311. it('should call EditorController.addFolder', function() {
  312. expect(
  313. this.EditorController.promises.addFolder
  314. ).to.have.been.calledWith(
  315. this.project._id,
  316. this.parentFolderId,
  317. this.folderName,
  318. 'editor'
  319. )
  320. })
  321. it('should send the folder back as JSON', function() {
  322. expect(this.res.json).to.have.been.calledWith(this.folder)
  323. })
  324. })
  325. describe('unsuccesfully', function() {
  326. it('handle name too short', function(done) {
  327. this.req.body.name = ''
  328. this.res.sendStatus.callsFake(status => {
  329. expect(status).to.equal(400)
  330. done()
  331. })
  332. this.EditorHttpController.addFolder(this.req, this.res)
  333. })
  334. it('handle too many files', function(done) {
  335. this.EditorController.promises.addFolder.rejects(
  336. new Error('project_has_too_many_files')
  337. )
  338. this.res.json.callsFake(payload => {
  339. expect(payload).to.equal('project_has_too_many_files')
  340. expect(this.res.status).to.have.been.calledWith(400)
  341. done()
  342. })
  343. this.res.status.returns(this.res)
  344. this.EditorHttpController.addFolder(this.req, this.res)
  345. })
  346. it('handle invalid element name', function(done) {
  347. this.EditorController.promises.addFolder.rejects(
  348. new Error('invalid element name')
  349. )
  350. this.res.json.callsFake(payload => {
  351. expect(payload).to.equal('invalid_file_name')
  352. expect(this.res.status).to.have.been.calledWith(400)
  353. done()
  354. })
  355. this.res.status.returns(this.res)
  356. this.EditorHttpController.addFolder(this.req, this.res)
  357. })
  358. })
  359. })
  360. describe('renameEntity', function() {
  361. beforeEach(function() {
  362. this.entityId = 'entity-id-123'
  363. this.entityType = 'entity-type'
  364. this.req.params = {
  365. Project_id: this.project._id,
  366. entity_id: this.entityId,
  367. entity_type: this.entityType
  368. }
  369. })
  370. describe('successfully', function() {
  371. beforeEach(function(done) {
  372. this.newName = 'new-name'
  373. this.req.body = { name: this.newName }
  374. this.res.sendStatus.callsFake(() => done())
  375. this.EditorHttpController.renameEntity(this.req, this.res)
  376. })
  377. it('should call EditorController.renameEntity', function() {
  378. expect(
  379. this.EditorController.promises.renameEntity
  380. ).to.have.been.calledWith(
  381. this.project._id,
  382. this.entityId,
  383. this.entityType,
  384. this.newName,
  385. this.user._id
  386. )
  387. })
  388. it('should send back a success response', function() {
  389. expect(this.res.sendStatus).to.have.been.calledWith(204)
  390. })
  391. })
  392. describe('with long name', function() {
  393. beforeEach(function() {
  394. this.newName = 'long'.repeat(100)
  395. this.req.body = { name: this.newName }
  396. this.EditorHttpController.renameEntity(this.req, this.res)
  397. })
  398. it('should send back a bad request status code', function() {
  399. expect(this.res.sendStatus).to.have.been.calledWith(400)
  400. })
  401. })
  402. describe('with 0 length name', function() {
  403. beforeEach(function() {
  404. this.newName = ''
  405. this.req.body = { name: this.newName }
  406. this.EditorHttpController.renameEntity(this.req, this.res)
  407. })
  408. it('should send back a bad request status code', function() {
  409. expect(this.res.sendStatus).to.have.been.calledWith(400)
  410. })
  411. })
  412. })
  413. describe('moveEntity', function() {
  414. beforeEach(function(done) {
  415. this.entityId = 'entity-id-123'
  416. this.entityType = 'entity-type'
  417. this.folderId = 'folder-id-123'
  418. this.req.params = {
  419. Project_id: this.project._id,
  420. entity_id: this.entityId,
  421. entity_type: this.entityType
  422. }
  423. this.req.body = { folder_id: this.folderId }
  424. this.res.sendStatus.callsFake(() => done())
  425. this.EditorHttpController.moveEntity(this.req, this.res)
  426. })
  427. it('should call EditorController.moveEntity', function() {
  428. expect(this.EditorController.promises.moveEntity).to.have.been.calledWith(
  429. this.project._id,
  430. this.entityId,
  431. this.folderId,
  432. this.entityType,
  433. this.user._id
  434. )
  435. })
  436. it('should send back a success response', function() {
  437. expect(this.res.sendStatus).to.have.been.calledWith(204)
  438. })
  439. })
  440. describe('deleteEntity', function() {
  441. beforeEach(function(done) {
  442. this.entityId = 'entity-id-123'
  443. this.entityType = 'entity-type'
  444. this.req.params = {
  445. Project_id: this.project._id,
  446. entity_id: this.entityId,
  447. entity_type: this.entityType
  448. }
  449. this.res.sendStatus.callsFake(() => done())
  450. this.EditorHttpController.deleteEntity(this.req, this.res)
  451. })
  452. it('should call EditorController.deleteEntity', function() {
  453. expect(
  454. this.EditorController.promises.deleteEntity
  455. ).to.have.been.calledWith(
  456. this.project._id,
  457. this.entityId,
  458. this.entityType,
  459. 'editor',
  460. this.user._id
  461. )
  462. })
  463. it('should send back a success response', function() {
  464. expect(this.res.sendStatus).to.have.been.calledWith(204)
  465. })
  466. })
  467. describe('convertDocToFile', function() {
  468. beforeEach(function(done) {
  469. this.req.params = {
  470. Project_id: this.project._id.toString(),
  471. entity_id: this.doc._id.toString()
  472. }
  473. this.req.body = { userId: this.user._id.toString() }
  474. this.res.json.callsFake(() => done())
  475. this.EditorHttpController.convertDocToFile(this.req, this.res)
  476. })
  477. describe('when successful', function() {
  478. it('should convert the doc to a file', function() {
  479. expect(
  480. this.ProjectEntityUpdateHandler.promises.convertDocToFile
  481. ).to.have.been.calledWith(
  482. this.project._id.toString(),
  483. this.doc._id.toString(),
  484. this.user._id.toString()
  485. )
  486. })
  487. it('should return the file id in the response', function() {
  488. expect(this.res.json).to.have.been.calledWith({
  489. fileId: this.file._id.toString()
  490. })
  491. })
  492. })
  493. describe('when the doc has ranges', function() {
  494. it('should return a 422', function(done) {
  495. this.ProjectEntityUpdateHandler.promises.convertDocToFile.rejects(
  496. new Errors.DocHasRangesError({})
  497. )
  498. this.EditorHttpController.convertDocToFile(this.req, this.res, err => {
  499. expect(err).to.be.instanceof(HttpErrors.UnprocessableEntityError)
  500. done()
  501. })
  502. })
  503. })
  504. })
  505. })