ProjectDetailsHandlerTests.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  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. })
  175. describe('validateProjectName', function () {
  176. it('should reject undefined names', async function () {
  177. await expect(this.handler.promises.validateProjectName(undefined)).to.be
  178. .rejected
  179. })
  180. it('should reject empty names', async function () {
  181. await expect(this.handler.promises.validateProjectName('')).to.be.rejected
  182. })
  183. it('should reject names with /s', async function () {
  184. await expect(this.handler.promises.validateProjectName('foo/bar')).to.be
  185. .rejected
  186. })
  187. it('should reject names with \\s', async function () {
  188. await expect(this.handler.promises.validateProjectName('foo\\bar')).to.be
  189. .rejected
  190. })
  191. it('should reject long names', async function () {
  192. await expect(this.handler.promises.validateProjectName('a'.repeat(1000)))
  193. .to.be.rejected
  194. })
  195. it('should accept normal names', async function () {
  196. await expect(this.handler.promises.validateProjectName('foobar')).to.be
  197. .fulfilled
  198. })
  199. })
  200. describe('generateUniqueName', function () {
  201. // actually testing `ProjectHelper.promises.ensureNameIsUnique()`
  202. beforeEach(function () {
  203. this.longName = 'x'.repeat(this.handler.MAX_PROJECT_NAME_LENGTH - 5)
  204. const usersProjects = {
  205. owned: [
  206. { _id: 1, name: 'name' },
  207. { _id: 2, name: 'name1' },
  208. { _id: 3, name: 'name11' },
  209. { _id: 100, name: 'numeric' },
  210. { _id: 101, name: 'numeric (1)' },
  211. { _id: 102, name: 'numeric (2)' },
  212. { _id: 103, name: 'numeric (3)' },
  213. { _id: 104, name: 'numeric (4)' },
  214. { _id: 105, name: 'numeric (5)' },
  215. { _id: 106, name: 'numeric (6)' },
  216. { _id: 107, name: 'numeric (7)' },
  217. { _id: 108, name: 'numeric (8)' },
  218. { _id: 109, name: 'numeric (9)' },
  219. { _id: 110, name: 'numeric (10)' },
  220. { _id: 111, name: 'numeric (11)' },
  221. { _id: 112, name: 'numeric (12)' },
  222. { _id: 113, name: 'numeric (13)' },
  223. { _id: 114, name: 'numeric (14)' },
  224. { _id: 115, name: 'numeric (15)' },
  225. { _id: 116, name: 'numeric (16)' },
  226. { _id: 117, name: 'numeric (17)' },
  227. { _id: 118, name: 'numeric (18)' },
  228. { _id: 119, name: 'numeric (19)' },
  229. { _id: 120, name: 'numeric (20)' },
  230. { _id: 130, name: 'numeric (30)' },
  231. { _id: 131, name: 'numeric (31)' },
  232. { _id: 132, name: 'numeric (32)' },
  233. { _id: 133, name: 'numeric (33)' },
  234. { _id: 134, name: 'numeric (34)' },
  235. { _id: 135, name: 'numeric (35)' },
  236. { _id: 136, name: 'numeric (36)' },
  237. { _id: 137, name: 'numeric (37)' },
  238. { _id: 138, name: 'numeric (38)' },
  239. { _id: 139, name: 'numeric (39)' },
  240. { _id: 140, name: 'numeric (40)' },
  241. { _id: 141, name: 'Yearbook (2021)' },
  242. { _id: 142, name: 'Yearbook (2021) (1)' },
  243. { _id: 142, name: 'Resume (2020' },
  244. ],
  245. readAndWrite: [
  246. { _id: 4, name: 'name2' },
  247. { _id: 5, name: 'name22' },
  248. ],
  249. readOnly: [
  250. { _id: 6, name: 'name3' },
  251. { _id: 7, name: 'name33' },
  252. ],
  253. tokenReadAndWrite: [
  254. { _id: 8, name: 'name4' },
  255. { _id: 9, name: 'name44' },
  256. ],
  257. tokenReadOnly: [
  258. { _id: 10, name: 'name5' },
  259. { _id: 11, name: 'name55' },
  260. { _id: 12, name: this.longName },
  261. ],
  262. }
  263. this.ProjectGetter.promises.findAllUsersProjects.resolves(usersProjects)
  264. })
  265. it('should leave a unique name unchanged', async function () {
  266. const name = await this.handler.promises.generateUniqueName(
  267. this.user._id,
  268. 'unique-name',
  269. ['-test-suffix']
  270. )
  271. expect(name).to.equal('unique-name')
  272. })
  273. it('should append a suffix to an existing name', async function () {
  274. const name = await this.handler.promises.generateUniqueName(
  275. this.user._id,
  276. 'name1',
  277. ['-test-suffix']
  278. )
  279. expect(name).to.equal('name1-test-suffix')
  280. })
  281. it('should fallback to a second suffix when needed', async function () {
  282. const name = await this.handler.promises.generateUniqueName(
  283. this.user._id,
  284. 'name1',
  285. ['1', '-test-suffix']
  286. )
  287. expect(name).to.equal('name1-test-suffix')
  288. })
  289. it('should truncate the name when append a suffix if the result is too long', async function () {
  290. const name = await this.handler.promises.generateUniqueName(
  291. this.user._id,
  292. this.longName,
  293. ['-test-suffix']
  294. )
  295. expect(name).to.equal(
  296. this.longName.substr(0, this.handler.MAX_PROJECT_NAME_LENGTH - 12) +
  297. '-test-suffix'
  298. )
  299. })
  300. it('should use a numeric index if no suffix is supplied', async function () {
  301. const name = await this.handler.promises.generateUniqueName(
  302. this.user._id,
  303. 'name1',
  304. []
  305. )
  306. expect(name).to.equal('name1 (1)')
  307. })
  308. it('should use a numeric index if all suffixes are exhausted', async function () {
  309. const name = await this.handler.promises.generateUniqueName(
  310. this.user._id,
  311. 'name',
  312. ['1', '11']
  313. )
  314. expect(name).to.equal('name (1)')
  315. })
  316. it('should find the next lowest available numeric index for the base name', async function () {
  317. const name = await this.handler.promises.generateUniqueName(
  318. this.user._id,
  319. 'numeric',
  320. []
  321. )
  322. expect(name).to.equal('numeric (21)')
  323. })
  324. it('should find the next available numeric index when a numeric index is already present', async function () {
  325. const name = await this.handler.promises.generateUniqueName(
  326. this.user._id,
  327. 'numeric (5)',
  328. []
  329. )
  330. expect(name).to.equal('numeric (21)')
  331. })
  332. it('should not find a numeric index lower than the one already present', async function () {
  333. const name = await this.handler.promises.generateUniqueName(
  334. this.user._id,
  335. 'numeric (31)',
  336. []
  337. )
  338. expect(name).to.equal('numeric (41)')
  339. })
  340. it('should handle years in name', async function () {
  341. const name = await this.handler.promises.generateUniqueName(
  342. this.user._id,
  343. 'unique-name (2021)',
  344. []
  345. )
  346. expect(name).to.equal('unique-name (2021)')
  347. })
  348. it('should handle duplicating with year in name', async function () {
  349. const name = await this.handler.promises.generateUniqueName(
  350. this.user._id,
  351. 'Yearbook (2021)',
  352. []
  353. )
  354. expect(name).to.equal('Yearbook (2021) (2)')
  355. })
  356. describe('title with that causes invalid regex', function () {
  357. it('should create the project with a suffix when project name exists', async function () {
  358. const name = await this.handler.promises.generateUniqueName(
  359. this.user._id,
  360. 'Resume (2020',
  361. []
  362. )
  363. expect(name).to.equal('Resume (2020 (1)')
  364. })
  365. it('should create the project with the provided name', async function () {
  366. const name = await this.handler.promises.generateUniqueName(
  367. this.user._id,
  368. 'Yearbook (2021',
  369. []
  370. )
  371. expect(name).to.equal('Yearbook (2021')
  372. })
  373. })
  374. })
  375. describe('fixProjectName', function () {
  376. it('should change empty names to Untitled', function () {
  377. expect(this.handler.fixProjectName('')).to.equal('Untitled')
  378. })
  379. it('should replace / with -', function () {
  380. expect(this.handler.fixProjectName('foo/bar')).to.equal('foo-bar')
  381. })
  382. it("should replace \\ with ''", function () {
  383. expect(this.handler.fixProjectName('foo \\ bar')).to.equal('foo bar')
  384. })
  385. it('should truncate long names', function () {
  386. expect(this.handler.fixProjectName('a'.repeat(1000))).to.equal(
  387. 'a'.repeat(150)
  388. )
  389. })
  390. it('should accept normal names', function () {
  391. expect(this.handler.fixProjectName('foobar')).to.equal('foobar')
  392. })
  393. })
  394. describe('setPublicAccessLevel', function () {
  395. beforeEach(function () {
  396. this.accessLevel = 'readOnly'
  397. })
  398. it('should update the project with the new level', async function () {
  399. await this.handler.promises.setPublicAccessLevel(
  400. this.project._id,
  401. this.accessLevel
  402. )
  403. expect(this.ProjectModel.updateOne).to.have.been.calledWith(
  404. { _id: this.project._id },
  405. { publicAccesLevel: this.accessLevel }
  406. )
  407. })
  408. it('should not produce an error', async function () {
  409. await expect(
  410. this.handler.promises.setPublicAccessLevel(
  411. this.project._id,
  412. this.accessLevel
  413. )
  414. ).to.be.fulfilled
  415. })
  416. describe('when update produces an error', function () {
  417. beforeEach(function () {
  418. this.ProjectModelUpdateQuery.exec.rejects(new Error('woops'))
  419. })
  420. it('should produce an error', async function () {
  421. await expect(
  422. this.handler.promises.setPublicAccessLevel(
  423. this.project._id,
  424. this.accessLevel
  425. )
  426. ).to.be.rejected
  427. })
  428. })
  429. })
  430. describe('ensureTokensArePresent', function () {
  431. describe('when the project has tokens', function () {
  432. beforeEach(function () {
  433. this.project = {
  434. _id: this.project._id,
  435. tokens: {
  436. readOnly: 'aaa',
  437. readAndWrite: '42bbb',
  438. readAndWritePrefix: '42',
  439. },
  440. }
  441. this.ProjectGetter.promises.getProject.resolves(this.project)
  442. })
  443. it('should get the project', async function () {
  444. await this.handler.promises.ensureTokensArePresent(this.project._id)
  445. expect(this.ProjectGetter.promises.getProject).to.have.been.calledOnce
  446. expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
  447. this.project._id,
  448. {
  449. tokens: 1,
  450. }
  451. )
  452. })
  453. it('should not update the project with new tokens', async function () {
  454. await this.handler.promises.ensureTokensArePresent(this.project._id)
  455. expect(this.ProjectModel.updateOne).not.to.have.been.called
  456. })
  457. it('should produce the tokens without error', async function () {
  458. const tokens = await this.handler.promises.ensureTokensArePresent(
  459. this.project._id
  460. )
  461. expect(tokens).to.deep.equal(this.project.tokens)
  462. })
  463. })
  464. describe('when tokens are missing', function () {
  465. beforeEach(function () {
  466. this.project = { _id: this.project._id }
  467. this.ProjectGetter.promises.getProject.resolves(this.project)
  468. this.readOnlyToken = 'abc'
  469. this.readAndWriteToken = '42def'
  470. this.readAndWriteTokenPrefix = '42'
  471. this.TokenGenerator.promises.generateUniqueReadOnlyToken.resolves(
  472. this.readOnlyToken
  473. )
  474. this.TokenGenerator.readAndWriteToken.returns({
  475. token: this.readAndWriteToken,
  476. numericPrefix: this.readAndWriteTokenPrefix,
  477. })
  478. })
  479. it('should get the project', async function () {
  480. await this.handler.promises.ensureTokensArePresent(this.project._id)
  481. expect(this.ProjectGetter.promises.getProject).to.have.been.calledOnce
  482. expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
  483. this.project._id,
  484. {
  485. tokens: 1,
  486. }
  487. )
  488. })
  489. it('should update the project with new tokens', async function () {
  490. await this.handler.promises.ensureTokensArePresent(this.project._id)
  491. expect(this.TokenGenerator.promises.generateUniqueReadOnlyToken).to.have
  492. .been.calledOnce
  493. expect(this.TokenGenerator.readAndWriteToken).to.have.been.calledOnce
  494. expect(this.ProjectModel.updateOne).to.have.been.calledOnce
  495. expect(this.ProjectModel.updateOne).to.have.been.calledWith(
  496. { _id: this.project._id },
  497. {
  498. $set: {
  499. tokens: {
  500. readOnly: this.readOnlyToken,
  501. readAndWrite: this.readAndWriteToken,
  502. readAndWritePrefix: this.readAndWriteTokenPrefix,
  503. },
  504. },
  505. }
  506. )
  507. })
  508. it('should produce the tokens without error', async function () {
  509. const tokens = await this.handler.promises.ensureTokensArePresent(
  510. this.project._id
  511. )
  512. expect(tokens).to.deep.equal({
  513. readOnly: this.readOnlyToken,
  514. readAndWrite: this.readAndWriteToken,
  515. readAndWritePrefix: this.readAndWriteTokenPrefix,
  516. })
  517. })
  518. })
  519. })
  520. describe('clearTokens', function () {
  521. it('clears the tokens from the project', async function () {
  522. await this.handler.promises.clearTokens(this.project._id)
  523. expect(this.ProjectModel.updateOne).to.have.been.calledWith(
  524. { _id: this.project._id },
  525. { $unset: { tokens: 1 }, $set: { publicAccesLevel: 'private' } }
  526. )
  527. })
  528. })
  529. })