ProjectDetailsHandlerTests.js 21 KB

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