ProjectDetailsHandlerTests.js 18 KB

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