ProjectDetailsHandlerTests.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  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 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: ObjectId(),
  12. email: 'user@example.com',
  13. features: 'mock-features',
  14. }
  15. this.collaborator = {
  16. _id: ObjectId(),
  17. email: 'collaborator@example.com',
  18. }
  19. this.project = {
  20. _id: 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. project_id: this.project._id,
  163. project_name: 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: 142, 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 find the next available numeric index when a numeric index is already present', async function () {
  335. const name = await this.handler.promises.generateUniqueName(
  336. this.user._id,
  337. 'numeric (5)',
  338. []
  339. )
  340. expect(name).to.equal('numeric (21)')
  341. })
  342. it('should not find a numeric index lower than the one already present', async function () {
  343. const name = await this.handler.promises.generateUniqueName(
  344. this.user._id,
  345. 'numeric (31)',
  346. []
  347. )
  348. expect(name).to.equal('numeric (41)')
  349. })
  350. it('should handle years in name', async function () {
  351. const name = await this.handler.promises.generateUniqueName(
  352. this.user._id,
  353. 'unique-name (2021)',
  354. []
  355. )
  356. expect(name).to.equal('unique-name (2021)')
  357. })
  358. it('should handle duplicating with year in name', async function () {
  359. const name = await this.handler.promises.generateUniqueName(
  360. this.user._id,
  361. 'Yearbook (2021)',
  362. []
  363. )
  364. expect(name).to.equal('Yearbook (2021) (2)')
  365. })
  366. describe('title with that causes invalid regex', function () {
  367. it('should create the project with a suffix when project name exists', async function () {
  368. const name = await this.handler.promises.generateUniqueName(
  369. this.user._id,
  370. 'Resume (2020',
  371. []
  372. )
  373. expect(name).to.equal('Resume (2020 (1)')
  374. })
  375. it('should create the project with the provided name', async function () {
  376. const name = await this.handler.promises.generateUniqueName(
  377. this.user._id,
  378. 'Yearbook (2021',
  379. []
  380. )
  381. expect(name).to.equal('Yearbook (2021')
  382. })
  383. })
  384. })
  385. describe('fixProjectName', function () {
  386. it('should change empty names to Untitled', function () {
  387. expect(this.handler.fixProjectName('')).to.equal('Untitled')
  388. })
  389. it('should replace / with -', function () {
  390. expect(this.handler.fixProjectName('foo/bar')).to.equal('foo-bar')
  391. })
  392. it("should replace \\ with ''", function () {
  393. expect(this.handler.fixProjectName('foo \\ bar')).to.equal('foo bar')
  394. })
  395. it('should truncate long names', function () {
  396. expect(this.handler.fixProjectName('a'.repeat(1000))).to.equal(
  397. 'a'.repeat(150)
  398. )
  399. })
  400. it('should accept normal names', function () {
  401. expect(this.handler.fixProjectName('foobar')).to.equal('foobar')
  402. })
  403. })
  404. describe('setPublicAccessLevel', function () {
  405. beforeEach(function () {
  406. this.accessLevel = 'readOnly'
  407. })
  408. it('should update the project with the new level', async function () {
  409. await this.handler.promises.setPublicAccessLevel(
  410. this.project._id,
  411. this.accessLevel
  412. )
  413. expect(this.ProjectModel.updateOne).to.have.been.calledWith(
  414. { _id: this.project._id },
  415. { publicAccesLevel: this.accessLevel }
  416. )
  417. })
  418. it('should not produce an error', async function () {
  419. await expect(
  420. this.handler.promises.setPublicAccessLevel(
  421. this.project._id,
  422. this.accessLevel
  423. )
  424. ).to.be.fulfilled
  425. })
  426. describe('when update produces an error', function () {
  427. beforeEach(function () {
  428. this.ProjectModelUpdateQuery.exec.rejects(new Error('woops'))
  429. })
  430. it('should produce an error', async function () {
  431. await expect(
  432. this.handler.promises.setPublicAccessLevel(
  433. this.project._id,
  434. this.accessLevel
  435. )
  436. ).to.be.rejected
  437. })
  438. })
  439. })
  440. describe('ensureTokensArePresent', function () {
  441. describe('when the project has tokens', function () {
  442. beforeEach(function () {
  443. this.project = {
  444. _id: this.project._id,
  445. tokens: {
  446. readOnly: 'aaa',
  447. readAndWrite: '42bbb',
  448. readAndWritePrefix: '42',
  449. },
  450. }
  451. this.ProjectGetter.promises.getProject.resolves(this.project)
  452. })
  453. it('should get the project', async function () {
  454. await this.handler.promises.ensureTokensArePresent(this.project._id)
  455. expect(this.ProjectGetter.promises.getProject).to.have.been.calledOnce
  456. expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
  457. this.project._id,
  458. {
  459. tokens: 1,
  460. }
  461. )
  462. })
  463. it('should not update the project with new tokens', async function () {
  464. await this.handler.promises.ensureTokensArePresent(this.project._id)
  465. expect(this.ProjectModel.updateOne).not.to.have.been.called
  466. })
  467. it('should produce the tokens without error', async function () {
  468. const tokens = await this.handler.promises.ensureTokensArePresent(
  469. this.project._id
  470. )
  471. expect(tokens).to.deep.equal(this.project.tokens)
  472. })
  473. })
  474. describe('when tokens are missing', function () {
  475. beforeEach(function () {
  476. this.project = { _id: this.project._id }
  477. this.ProjectGetter.promises.getProject.resolves(this.project)
  478. this.readOnlyToken = 'abc'
  479. this.readAndWriteToken = '42def'
  480. this.readAndWriteTokenPrefix = '42'
  481. this.TokenGenerator.promises.generateUniqueReadOnlyToken.resolves(
  482. this.readOnlyToken
  483. )
  484. this.TokenGenerator.readAndWriteToken.returns({
  485. token: this.readAndWriteToken,
  486. numericPrefix: this.readAndWriteTokenPrefix,
  487. })
  488. })
  489. it('should get the project', async function () {
  490. await this.handler.promises.ensureTokensArePresent(this.project._id)
  491. expect(this.ProjectGetter.promises.getProject).to.have.been.calledOnce
  492. expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
  493. this.project._id,
  494. {
  495. tokens: 1,
  496. }
  497. )
  498. })
  499. it('should update the project with new tokens', async function () {
  500. await this.handler.promises.ensureTokensArePresent(this.project._id)
  501. expect(this.TokenGenerator.promises.generateUniqueReadOnlyToken).to.have
  502. .been.calledOnce
  503. expect(this.TokenGenerator.readAndWriteToken).to.have.been.calledOnce
  504. expect(this.ProjectModel.updateOne).to.have.been.calledOnce
  505. expect(this.ProjectModel.updateOne).to.have.been.calledWith(
  506. { _id: this.project._id },
  507. {
  508. $set: {
  509. tokens: {
  510. readOnly: this.readOnlyToken,
  511. readAndWrite: this.readAndWriteToken,
  512. readAndWritePrefix: this.readAndWriteTokenPrefix,
  513. },
  514. },
  515. }
  516. )
  517. })
  518. it('should produce the tokens without error', async function () {
  519. const tokens = await this.handler.promises.ensureTokensArePresent(
  520. this.project._id
  521. )
  522. expect(tokens).to.deep.equal({
  523. readOnly: this.readOnlyToken,
  524. readAndWrite: this.readAndWriteToken,
  525. readAndWritePrefix: this.readAndWriteTokenPrefix,
  526. })
  527. })
  528. })
  529. })
  530. describe('clearTokens', function () {
  531. it('clears the tokens from the project', async function () {
  532. await this.handler.promises.clearTokens(this.project._id)
  533. expect(this.ProjectModel.updateOne).to.have.been.calledWith(
  534. { _id: this.project._id },
  535. { $unset: { tokens: 1 }, $set: { publicAccesLevel: 'private' } }
  536. )
  537. })
  538. })
  539. })