ProjectDetailsHandlerTests.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  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 PrivilegeLevels = require('../../../../app/src/Features/Authorization/PrivilegeLevels')
  7. const MODULE_PATH = '../../../../app/src/Features/Project/ProjectDetailsHandler'
  8. describe('ProjectDetailsHandler', function() {
  9. beforeEach(function() {
  10. this.user = {
  11. _id: ObjectId('abcdefabcdefabcdefabcdef'),
  12. features: 'mock-features'
  13. }
  14. this.collaborator = {
  15. _id: ObjectId('123456123456123456123456')
  16. }
  17. this.project = {
  18. _id: ObjectId('5d5dabdbb351de090cdff0b2'),
  19. name: 'project',
  20. description: 'this is a great project',
  21. something: 'should not exist',
  22. compiler: 'latexxxxxx',
  23. owner_ref: this.user._id,
  24. collaberator_refs: [this.collaborator._id]
  25. }
  26. this.ProjectGetter = {
  27. promises: {
  28. getProjectWithoutDocLines: sinon.stub().resolves(this.project),
  29. getProject: sinon.stub().resolves(this.project),
  30. findAllUsersProjects: sinon.stub().resolves({
  31. owned: [],
  32. readAndWrite: [],
  33. readOnly: [],
  34. tokenReadAndWrite: [],
  35. tokenReadOnly: []
  36. })
  37. }
  38. }
  39. this.ProjectModel = {
  40. update: sinon.stub().returns({
  41. exec: sinon.stub().resolves()
  42. })
  43. }
  44. this.UserGetter = {
  45. promises: {
  46. getUser: sinon.stub().resolves(this.user)
  47. }
  48. }
  49. this.TpdsUpdateSender = {
  50. promises: {
  51. moveEntity: sinon.stub().resolves()
  52. }
  53. }
  54. this.ProjectEntityHandler = {
  55. promises: {
  56. flushProjectToThirdPartyDataStore: sinon.stub().resolves()
  57. }
  58. }
  59. this.CollaboratorsHandler = {
  60. promises: {
  61. removeUserFromProject: sinon.stub().resolves(),
  62. addUserIdToProject: sinon.stub().resolves()
  63. }
  64. }
  65. this.ProjectTokenGenerator = {
  66. readAndWriteToken: sinon.stub(),
  67. promises: {
  68. generateUniqueReadOnlyToken: sinon.stub()
  69. }
  70. }
  71. this.settings = {
  72. defaultFeatures: 'default-features'
  73. }
  74. this.handler = SandboxedModule.require(MODULE_PATH, {
  75. globals: {
  76. console: console
  77. },
  78. requires: {
  79. './ProjectGetter': this.ProjectGetter,
  80. '../../models/Project': {
  81. Project: this.ProjectModel
  82. },
  83. '../User/UserGetter': this.UserGetter,
  84. '../ThirdPartyDataStore/TpdsUpdateSender': this.TpdsUpdateSender,
  85. './ProjectEntityHandler': this.ProjectEntityHandler,
  86. '../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler,
  87. 'logger-sharelatex': {
  88. log() {},
  89. warn() {},
  90. err() {}
  91. },
  92. './ProjectTokenGenerator': this.ProjectTokenGenerator,
  93. '../Errors/Errors': Errors,
  94. 'settings-sharelatex': this.settings
  95. }
  96. })
  97. })
  98. describe('getDetails', function() {
  99. it('should find the project and owner', async function() {
  100. const details = await this.handler.promises.getDetails(this.project._id)
  101. details.name.should.equal(this.project.name)
  102. details.description.should.equal(this.project.description)
  103. details.compiler.should.equal(this.project.compiler)
  104. details.features.should.equal(this.user.features)
  105. expect(details.something).to.be.undefined
  106. })
  107. it('should find overleaf metadata if it exists', async function() {
  108. this.project.overleaf = { id: 'id' }
  109. const details = await this.handler.promises.getDetails(this.project._id)
  110. details.overleaf.should.equal(this.project.overleaf)
  111. expect(details.something).to.be.undefined
  112. })
  113. it('should return an error for a non-existent project', async function() {
  114. this.ProjectGetter.promises.getProject.resolves(null)
  115. await expect(
  116. this.handler.promises.getDetails('0123456789012345678901234')
  117. ).to.be.rejectedWith(Errors.NotFoundError)
  118. })
  119. it('should return the default features if no owner found', async function() {
  120. this.UserGetter.promises.getUser.resolves(null)
  121. const details = await this.handler.promises.getDetails(this.project._id)
  122. details.features.should.equal(this.settings.defaultFeatures)
  123. })
  124. it('should rethrow any error', async function() {
  125. this.ProjectGetter.promises.getProject.rejects(new Error('boom'))
  126. await expect(this.handler.promises.getDetails(this.project._id)).to.be
  127. .rejected
  128. })
  129. })
  130. describe('transferOwnership', function() {
  131. beforeEach(function() {
  132. this.UserGetter.promises.getUser.resolves(this.collaborator)
  133. })
  134. it("should return a not found error if the project can't be found", async function() {
  135. this.ProjectGetter.promises.getProject.resolves(null)
  136. await expect(
  137. this.handler.promises.transferOwnership('abc', this.collaborator._id)
  138. ).to.be.rejectedWith(Errors.ProjectNotFoundError)
  139. })
  140. it("should return a not found error if the user can't be found", async function() {
  141. this.UserGetter.promises.getUser.resolves(null)
  142. await expect(
  143. this.handler.promises.transferOwnership(
  144. this.project._id,
  145. this.collaborator._id
  146. )
  147. ).to.be.rejectedWith(Errors.UserNotFoundError)
  148. })
  149. it('should return an error if user cannot be removed as collaborator ', async function() {
  150. this.CollaboratorsHandler.promises.removeUserFromProject.rejects(
  151. new Error('user-cannot-be-removed')
  152. )
  153. await expect(
  154. this.handler.promises.transferOwnership(
  155. this.project._id,
  156. this.collaborator._id
  157. )
  158. ).to.be.rejected
  159. })
  160. it('should transfer ownership of the project', async function() {
  161. await this.handler.promises.transferOwnership(
  162. this.project._id,
  163. this.collaborator._id
  164. )
  165. expect(this.ProjectModel.update).to.have.been.calledWith(
  166. { _id: this.project._id },
  167. sinon.match({ $set: { owner_ref: this.collaborator._id } })
  168. )
  169. })
  170. it('should do nothing if transferring back to the owner', async function() {
  171. await this.handler.promises.transferOwnership(
  172. this.project._id,
  173. this.user._id
  174. )
  175. expect(this.ProjectModel.update).not.to.have.been.called
  176. })
  177. it("should remove the user from the project's collaborators", async function() {
  178. await this.handler.promises.transferOwnership(
  179. this.project._id,
  180. this.collaborator._id
  181. )
  182. expect(
  183. this.CollaboratorsHandler.promises.removeUserFromProject
  184. ).to.have.been.calledWith(this.project._id, this.collaborator._id)
  185. })
  186. it('should add the former project owner as a read/write collaborator', async function() {
  187. await this.handler.promises.transferOwnership(
  188. this.project._id,
  189. this.collaborator._id
  190. )
  191. expect(
  192. this.CollaboratorsHandler.promises.addUserIdToProject
  193. ).to.have.been.calledWith(
  194. this.project._id,
  195. this.collaborator._id,
  196. this.user._id,
  197. PrivilegeLevels.READ_AND_WRITE
  198. )
  199. })
  200. it('should flush the project to tpds', async function() {
  201. await this.handler.promises.transferOwnership(
  202. this.project._id,
  203. this.collaborator._id
  204. )
  205. expect(
  206. this.ProjectEntityHandler.promises.flushProjectToThirdPartyDataStore
  207. ).to.have.been.calledWith(this.project._id)
  208. })
  209. it('should decline to transfer ownership to a non-collaborator', async function() {
  210. this.project.collaberator_refs = []
  211. await expect(
  212. this.handler.promises.transferOwnership(
  213. this.project._id,
  214. this.collaborator._id
  215. )
  216. ).to.be.rejectedWith(Errors.UserNotCollaboratorError)
  217. })
  218. })
  219. describe('getProjectDescription', function() {
  220. it('should make a call to mongo just for the description', async function() {
  221. this.ProjectGetter.promises.getProject.resolves()
  222. await this.handler.promises.getProjectDescription(this.project._id)
  223. expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
  224. this.project._id,
  225. { description: true }
  226. )
  227. })
  228. it('should return what the mongo call returns', async function() {
  229. const expectedDescription = 'cool project'
  230. this.ProjectGetter.promises.getProject.resolves({
  231. description: expectedDescription
  232. })
  233. const description = await this.handler.promises.getProjectDescription(
  234. this.project._id
  235. )
  236. expect(description).to.equal(expectedDescription)
  237. })
  238. })
  239. describe('setProjectDescription', function() {
  240. beforeEach(function() {
  241. this.description = 'updated teh description'
  242. })
  243. it('should update the project detials', async function() {
  244. await this.handler.promises.setProjectDescription(
  245. this.project._id,
  246. this.description
  247. )
  248. expect(this.ProjectModel.update).to.have.been.calledWith(
  249. { _id: this.project._id },
  250. { description: this.description }
  251. )
  252. })
  253. })
  254. describe('renameProject', function() {
  255. beforeEach(function() {
  256. this.newName = 'new name here'
  257. })
  258. it('should update the project with the new name', async function() {
  259. await this.handler.promises.renameProject(this.project._id, this.newName)
  260. expect(this.ProjectModel.update).to.have.been.calledWith(
  261. { _id: this.project._id },
  262. { name: this.newName }
  263. )
  264. })
  265. it('should tell the TpdsUpdateSender', async function() {
  266. await this.handler.promises.renameProject(this.project._id, this.newName)
  267. expect(this.TpdsUpdateSender.promises.moveEntity).to.have.been.calledWith(
  268. {
  269. project_id: this.project._id,
  270. project_name: this.project.name,
  271. newProjectName: this.newName
  272. }
  273. )
  274. })
  275. it('should not do anything with an invalid name', async function() {
  276. await expect(this.handler.promises.renameProject(this.project._id)).to.be
  277. .rejected
  278. expect(this.TpdsUpdateSender.promises.moveEntity).not.to.have.been.called
  279. expect(this.ProjectModel.update).not.to.have.been.called
  280. })
  281. })
  282. describe('validateProjectName', function() {
  283. it('should reject undefined names', async function() {
  284. await expect(this.handler.promises.validateProjectName(undefined)).to.be
  285. .rejected
  286. })
  287. it('should reject empty names', async function() {
  288. await expect(this.handler.promises.validateProjectName('')).to.be.rejected
  289. })
  290. it('should reject names with /s', async function() {
  291. await expect(this.handler.promises.validateProjectName('foo/bar')).to.be
  292. .rejected
  293. })
  294. it('should reject names with \\s', async function() {
  295. await expect(this.handler.promises.validateProjectName('foo\\bar')).to.be
  296. .rejected
  297. })
  298. it('should reject long names', async function() {
  299. await expect(this.handler.promises.validateProjectName('a'.repeat(1000)))
  300. .to.be.rejected
  301. })
  302. it('should accept normal names', async function() {
  303. await expect(this.handler.promises.validateProjectName('foobar')).to.be
  304. .resolved
  305. })
  306. })
  307. describe('generateUniqueName', function() {
  308. beforeEach(function() {
  309. this.longName = 'x'.repeat(this.handler.MAX_PROJECT_NAME_LENGTH - 5)
  310. const usersProjects = {
  311. owned: [
  312. { _id: 1, name: 'name' },
  313. { _id: 2, name: 'name1' },
  314. { _id: 3, name: 'name11' },
  315. { _id: 100, name: 'numeric' },
  316. { _id: 101, name: 'numeric (1)' },
  317. { _id: 102, name: 'numeric (2)' },
  318. { _id: 103, name: 'numeric (3)' },
  319. { _id: 104, name: 'numeric (4)' },
  320. { _id: 105, name: 'numeric (5)' },
  321. { _id: 106, name: 'numeric (6)' },
  322. { _id: 107, name: 'numeric (7)' },
  323. { _id: 108, name: 'numeric (8)' },
  324. { _id: 109, name: 'numeric (9)' },
  325. { _id: 110, name: 'numeric (10)' },
  326. { _id: 111, name: 'numeric (11)' },
  327. { _id: 112, name: 'numeric (12)' },
  328. { _id: 113, name: 'numeric (13)' },
  329. { _id: 114, name: 'numeric (14)' },
  330. { _id: 115, name: 'numeric (15)' },
  331. { _id: 116, name: 'numeric (16)' },
  332. { _id: 117, name: 'numeric (17)' },
  333. { _id: 118, name: 'numeric (18)' },
  334. { _id: 119, name: 'numeric (19)' },
  335. { _id: 120, name: 'numeric (20)' },
  336. { _id: 130, name: 'numeric (30)' },
  337. { _id: 131, name: 'numeric (31)' },
  338. { _id: 132, name: 'numeric (32)' },
  339. { _id: 133, name: 'numeric (33)' },
  340. { _id: 134, name: 'numeric (34)' },
  341. { _id: 135, name: 'numeric (35)' },
  342. { _id: 136, name: 'numeric (36)' },
  343. { _id: 137, name: 'numeric (37)' },
  344. { _id: 138, name: 'numeric (38)' },
  345. { _id: 139, name: 'numeric (39)' },
  346. { _id: 140, name: 'numeric (40)' }
  347. ],
  348. readAndWrite: [{ _id: 4, name: 'name2' }, { _id: 5, name: 'name22' }],
  349. readOnly: [{ _id: 6, name: 'name3' }, { _id: 7, name: 'name33' }],
  350. tokenReadAndWrite: [
  351. { _id: 8, name: 'name4' },
  352. { _id: 9, name: 'name44' }
  353. ],
  354. tokenReadOnly: [
  355. { _id: 10, name: 'name5' },
  356. { _id: 11, name: 'name55' },
  357. { _id: 12, name: this.longName }
  358. ]
  359. }
  360. this.ProjectGetter.promises.findAllUsersProjects.resolves(usersProjects)
  361. })
  362. it('should leave a unique name unchanged', async function() {
  363. const name = await this.handler.promises.generateUniqueName(
  364. this.user._id,
  365. 'unique-name',
  366. ['-test-suffix']
  367. )
  368. expect(name).to.equal('unique-name')
  369. })
  370. it('should append a suffix to an existing name', async function() {
  371. const name = await this.handler.promises.generateUniqueName(
  372. this.user._id,
  373. 'name1',
  374. ['-test-suffix']
  375. )
  376. expect(name).to.equal('name1-test-suffix')
  377. })
  378. it('should fallback to a second suffix when needed', async function() {
  379. const name = await this.handler.promises.generateUniqueName(
  380. this.user._id,
  381. 'name1',
  382. ['1', '-test-suffix']
  383. )
  384. expect(name).to.equal('name1-test-suffix')
  385. })
  386. it('should truncate the name when append a suffix if the result is too long', async function() {
  387. const name = await this.handler.promises.generateUniqueName(
  388. this.user._id,
  389. this.longName,
  390. ['-test-suffix']
  391. )
  392. expect(name).to.equal(
  393. this.longName.substr(0, this.handler.MAX_PROJECT_NAME_LENGTH - 12) +
  394. '-test-suffix'
  395. )
  396. })
  397. it('should use a numeric index if no suffix is supplied', async function() {
  398. const name = await this.handler.promises.generateUniqueName(
  399. this.user._id,
  400. 'name1',
  401. []
  402. )
  403. expect(name).to.equal('name1 (1)')
  404. })
  405. it('should use a numeric index if all suffixes are exhausted', async function() {
  406. const name = await this.handler.promises.generateUniqueName(
  407. this.user._id,
  408. 'name',
  409. ['1', '11']
  410. )
  411. expect(name).to.equal('name (1)')
  412. })
  413. it('should find the next lowest available numeric index for the base name', async function() {
  414. const name = await this.handler.promises.generateUniqueName(
  415. this.user._id,
  416. 'numeric',
  417. []
  418. )
  419. expect(name).to.equal('numeric (21)')
  420. })
  421. it('should find the next available numeric index when a numeric index is already present', async function() {
  422. const name = await this.handler.promises.generateUniqueName(
  423. this.user._id,
  424. 'numeric (5)',
  425. []
  426. )
  427. expect(name).to.equal('numeric (21)')
  428. })
  429. it('should not find a numeric index lower than the one already present', async function() {
  430. const name = await this.handler.promises.generateUniqueName(
  431. this.user._id,
  432. 'numeric (31)',
  433. []
  434. )
  435. expect(name).to.equal('numeric (41)')
  436. })
  437. })
  438. describe('fixProjectName', function() {
  439. it('should change empty names to Untitled', function() {
  440. expect(this.handler.fixProjectName('')).to.equal('Untitled')
  441. })
  442. it('should replace / with -', function() {
  443. expect(this.handler.fixProjectName('foo/bar')).to.equal('foo-bar')
  444. })
  445. it("should replace \\ with ''", function() {
  446. expect(this.handler.fixProjectName('foo \\ bar')).to.equal('foo bar')
  447. })
  448. it('should truncate long names', function() {
  449. expect(this.handler.fixProjectName('a'.repeat(1000))).to.equal(
  450. 'a'.repeat(150)
  451. )
  452. })
  453. it('should accept normal names', function() {
  454. expect(this.handler.fixProjectName('foobar')).to.equal('foobar')
  455. })
  456. })
  457. describe('setPublicAccessLevel', function() {
  458. beforeEach(function() {
  459. this.accessLevel = 'readOnly'
  460. })
  461. it('should update the project with the new level', async function() {
  462. await this.handler.promises.setPublicAccessLevel(
  463. this.project._id,
  464. this.accessLevel
  465. )
  466. expect(this.ProjectModel.update).to.have.been.calledWith(
  467. { _id: this.project._id },
  468. { publicAccesLevel: this.accessLevel }
  469. )
  470. })
  471. it('should not produce an error', async function() {
  472. await expect(
  473. this.handler.promises.setPublicAccessLevel(
  474. this.project._id,
  475. this.accessLevel
  476. )
  477. ).to.be.resolved
  478. })
  479. describe('when update produces an error', function() {
  480. beforeEach(function() {
  481. this.ProjectModel.update.rejects(new Error('woops'))
  482. })
  483. it('should produce an error', async function() {
  484. await expect(
  485. this.handler.promises.setPublicAccessLevel(
  486. this.project._id,
  487. this.accessLevel
  488. )
  489. ).to.be.rejected
  490. })
  491. })
  492. })
  493. describe('ensureTokensArePresent', function() {
  494. describe('when the project has tokens', function() {
  495. beforeEach(function() {
  496. this.project = {
  497. _id: this.project._id,
  498. tokens: {
  499. readOnly: 'aaa',
  500. readAndWrite: '42bbb',
  501. readAndWritePrefix: '42'
  502. }
  503. }
  504. this.ProjectGetter.promises.getProject.resolves(this.project)
  505. })
  506. it('should get the project', async function() {
  507. await this.handler.promises.ensureTokensArePresent(this.project._id)
  508. expect(this.ProjectGetter.promises.getProject).to.have.been.calledOnce
  509. expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
  510. this.project._id,
  511. {
  512. tokens: 1
  513. }
  514. )
  515. })
  516. it('should not update the project with new tokens', async function() {
  517. await this.handler.promises.ensureTokensArePresent(this.project._id)
  518. expect(this.ProjectModel.update).not.to.have.been.called
  519. })
  520. it('should produce the tokens without error', async function() {
  521. const tokens = await this.handler.promises.ensureTokensArePresent(
  522. this.project._id
  523. )
  524. expect(tokens).to.deep.equal(this.project.tokens)
  525. })
  526. })
  527. describe('when tokens are missing', function() {
  528. beforeEach(function() {
  529. this.project = { _id: this.project._id }
  530. this.ProjectGetter.promises.getProject.resolves(this.project)
  531. this.readOnlyToken = 'abc'
  532. this.readAndWriteToken = '42def'
  533. this.readAndWriteTokenPrefix = '42'
  534. this.ProjectTokenGenerator.promises.generateUniqueReadOnlyToken.resolves(
  535. this.readOnlyToken
  536. )
  537. this.ProjectTokenGenerator.readAndWriteToken.returns({
  538. token: this.readAndWriteToken,
  539. numericPrefix: this.readAndWriteTokenPrefix
  540. })
  541. })
  542. it('should get the project', async function() {
  543. await this.handler.promises.ensureTokensArePresent(this.project._id)
  544. expect(this.ProjectGetter.promises.getProject).to.have.been.calledOnce
  545. expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
  546. this.project._id,
  547. {
  548. tokens: 1
  549. }
  550. )
  551. })
  552. it('should update the project with new tokens', async function() {
  553. await this.handler.promises.ensureTokensArePresent(this.project._id)
  554. expect(this.ProjectTokenGenerator.promises.generateUniqueReadOnlyToken)
  555. .to.have.been.calledOnce
  556. expect(this.ProjectTokenGenerator.readAndWriteToken).to.have.been
  557. .calledOnce
  558. expect(this.ProjectModel.update).to.have.been.calledOnce
  559. expect(this.ProjectModel.update).to.have.been.calledWith(
  560. { _id: this.project._id },
  561. {
  562. $set: {
  563. tokens: {
  564. readOnly: this.readOnlyToken,
  565. readAndWrite: this.readAndWriteToken,
  566. readAndWritePrefix: this.readAndWriteTokenPrefix
  567. }
  568. }
  569. }
  570. )
  571. })
  572. it('should produce the tokens without error', async function() {
  573. const tokens = await this.handler.promises.ensureTokensArePresent(
  574. this.project._id
  575. )
  576. expect(tokens).to.deep.equal({
  577. readOnly: this.readOnlyToken,
  578. readAndWrite: this.readAndWriteToken,
  579. readAndWritePrefix: this.readAndWriteTokenPrefix
  580. })
  581. })
  582. })
  583. })
  584. })