ProjectDetailsHandlerTests.js 19 KB

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