TokenAccessController.test.mjs 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143
  1. import esmock from 'esmock'
  2. import sinon from 'sinon'
  3. import { expect } from 'chai'
  4. import mongodb from 'mongodb-legacy'
  5. import MockRequest from '../helpers/MockRequest.js'
  6. import MockResponse from '../helpers/MockResponse.js'
  7. import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.js'
  8. import { getSafeRedirectPath } from '../../../../app/src/Features/Helpers/UrlHelper.js'
  9. const ObjectId = mongodb.ObjectId
  10. const MODULE_PATH =
  11. '../../../../app/src/Features/TokenAccess/TokenAccessController'
  12. describe('TokenAccessController', function () {
  13. beforeEach(async function () {
  14. this.token = 'abc123'
  15. this.user = { _id: new ObjectId() }
  16. this.project = {
  17. _id: new ObjectId(),
  18. owner_ref: this.user._id,
  19. name: 'test',
  20. tokenAccessReadAndWrite_refs: [],
  21. tokenAccessReadOnly_refs: [],
  22. }
  23. this.req = new MockRequest()
  24. this.res = new MockResponse()
  25. this.next = sinon.stub().returns()
  26. this.Settings = {
  27. siteUrl: 'https://www.dev-overleaf.com',
  28. adminPrivilegeAvailable: false,
  29. adminUrl: 'https://admin.dev-overleaf.com',
  30. adminDomains: ['overleaf.com'],
  31. }
  32. this.TokenAccessHandler = {
  33. TOKEN_TYPES: {
  34. READ_ONLY: 'readOnly',
  35. READ_AND_WRITE: 'readAndWrite',
  36. },
  37. isReadAndWriteToken: sinon.stub().returns(true),
  38. isReadOnlyToken: sinon.stub().returns(true),
  39. tokenAccessEnabledForProject: sinon.stub().returns(true),
  40. checkTokenHashPrefix: sinon.stub(),
  41. makeTokenUrl: sinon.stub().returns('/'),
  42. grantSessionTokenAccess: sinon.stub(),
  43. promises: {
  44. addReadOnlyUserToProject: sinon.stub().resolves(),
  45. getProjectByToken: sinon.stub().resolves(this.project),
  46. getV1DocPublishedInfo: sinon.stub().resolves({ allow: true }),
  47. getV1DocInfo: sinon.stub(),
  48. removeReadAndWriteUserFromProject: sinon.stub().resolves(),
  49. moveReadAndWriteUserToReadOnly: sinon.stub().resolves(),
  50. },
  51. }
  52. this.SessionManager = {
  53. getLoggedInUserId: sinon.stub().returns(this.user._id),
  54. getSessionUser: sinon.stub().returns(this.user._id),
  55. }
  56. this.AuthenticationController = {
  57. setRedirectInSession: sinon.stub(),
  58. }
  59. this.AuthorizationManager = {
  60. promises: {
  61. getPrivilegeLevelForProject: sinon
  62. .stub()
  63. .resolves(PrivilegeLevels.NONE),
  64. },
  65. }
  66. this.AuthorizationMiddleware = {}
  67. this.ProjectAuditLogHandler = {
  68. promises: {
  69. addEntry: sinon.stub().resolves(),
  70. },
  71. }
  72. this.SplitTestHandler = {
  73. promises: {
  74. getAssignment: sinon.stub().resolves({ variant: 'default' }),
  75. getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
  76. },
  77. }
  78. this.CollaboratorsInviteHandler = {
  79. promises: {
  80. revokeInviteForUser: sinon.stub().resolves(),
  81. },
  82. }
  83. this.CollaboratorsHandler = {
  84. promises: {
  85. addUserIdToProject: sinon.stub().resolves(),
  86. setCollaboratorPrivilegeLevel: sinon.stub().resolves(),
  87. },
  88. }
  89. this.CollaboratorsGetter = {
  90. promises: {
  91. userIsReadWriteTokenMember: sinon.stub().resolves(),
  92. isUserInvitedReadWriteMemberOfProject: sinon.stub().resolves(),
  93. isUserInvitedMemberOfProject: sinon.stub().resolves(),
  94. },
  95. }
  96. this.EditorRealTimeController = { emitToRoom: sinon.stub() }
  97. this.ProjectGetter = {
  98. promises: {
  99. getProject: sinon.stub().resolves(this.project),
  100. },
  101. }
  102. this.AnalyticsManager = {
  103. recordEventForSession: sinon.stub(),
  104. recordEventForUserInBackground: sinon.stub(),
  105. }
  106. this.UserGetter = {
  107. promises: {
  108. getUser: sinon.stub().callsFake(async (userId, filter) => {
  109. if (userId === this.userId) {
  110. return this.user
  111. } else {
  112. return null
  113. }
  114. }),
  115. getUserEmail: sinon.stub().resolves(),
  116. getUserConfirmedEmails: sinon.stub().resolves(),
  117. },
  118. }
  119. this.LimitationsManager = {
  120. promises: {
  121. canAcceptEditCollaboratorInvite: sinon.stub().resolves(),
  122. },
  123. }
  124. this.TokenAccessController = await esmock.strict(MODULE_PATH, {
  125. '@overleaf/settings': this.Settings,
  126. '../../../../app/src/Features/TokenAccess/TokenAccessHandler':
  127. this.TokenAccessHandler,
  128. '../../../../app/src/Features/Authentication/AuthenticationController':
  129. this.AuthenticationController,
  130. '../../../../app/src/Features/Authentication/SessionManager':
  131. this.SessionManager,
  132. '../../../../app/src/Features/Authorization/AuthorizationManager':
  133. this.AuthorizationManager,
  134. '../../../../app/src/Features/Authorization/AuthorizationMiddleware':
  135. this.AuthorizationMiddleware,
  136. '../../../../app/src/Features/Project/ProjectAuditLogHandler':
  137. this.ProjectAuditLogHandler,
  138. '../../../../app/src/Features/SplitTests/SplitTestHandler':
  139. this.SplitTestHandler,
  140. '../../../../app/src/Features/Errors/Errors': (this.Errors = {
  141. NotFoundError: sinon.stub(),
  142. }),
  143. '../../../../app/src/Features/Collaborators/CollaboratorsHandler':
  144. this.CollaboratorsHandler,
  145. '../../../../app/src/Features/Collaborators/CollaboratorsInviteHandler':
  146. this.CollaboratorsInviteHandler,
  147. '../../../../app/src/Features/Collaborators/CollaboratorsGetter':
  148. this.CollaboratorsGetter,
  149. '../../../../app/src/Features/Editor/EditorRealTimeController':
  150. this.EditorRealTimeController,
  151. '../../../../app/src/Features/Project/ProjectGetter': this.ProjectGetter,
  152. '../../../../app/src/Features/Helpers/AsyncFormHelper':
  153. (this.AsyncFormHelper = {
  154. redirect: sinon.stub(),
  155. }),
  156. '../../../../app/src/Features/Helpers/AdminAuthorizationHelper':
  157. (this.AdminAuthorizationHelper = {
  158. canRedirectToAdminDomain: sinon.stub(),
  159. }),
  160. '../../../../app/src/Features/Helpers/UrlHelper': (this.UrlHelper = {
  161. getSafeAdminDomainRedirect: sinon
  162. .stub()
  163. .callsFake(
  164. path => `${this.Settings.adminUrl}${getSafeRedirectPath(path)}`
  165. ),
  166. }),
  167. '../../../../app/src/Features/Analytics/AnalyticsManager':
  168. this.AnalyticsManager,
  169. '../../../../app/src/Features/User/UserGetter': this.UserGetter,
  170. '../../../../app/src/Features/Subscription/LimitationsManager':
  171. this.LimitationsManager,
  172. })
  173. })
  174. describe('grantTokenAccessReadAndWrite', function () {
  175. beforeEach(function () {
  176. this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
  177. true
  178. )
  179. })
  180. describe('normal case (edit slot available)', function () {
  181. beforeEach(function (done) {
  182. this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
  183. true
  184. )
  185. this.req.params = { token: this.token }
  186. this.req.body = {
  187. confirmedByUser: true,
  188. tokenHashPrefix: '#prefix',
  189. }
  190. this.res.callback = done
  191. this.TokenAccessController.grantTokenAccessReadAndWrite(
  192. this.req,
  193. this.res,
  194. done
  195. )
  196. })
  197. it('adds the user as a read and write invited member', function () {
  198. expect(
  199. this.CollaboratorsHandler.promises.addUserIdToProject
  200. ).to.have.been.calledWith(
  201. this.project._id,
  202. undefined,
  203. this.user._id,
  204. PrivilegeLevels.READ_AND_WRITE
  205. )
  206. })
  207. it('writes a project audit log', function () {
  208. expect(
  209. this.ProjectAuditLogHandler.promises.addEntry
  210. ).to.have.been.calledWith(
  211. this.project._id,
  212. 'accept-via-link-sharing',
  213. this.user._id,
  214. this.req.ip,
  215. { privileges: 'readAndWrite' }
  216. )
  217. })
  218. it('records a project-joined event for the user', function () {
  219. expect(
  220. this.AnalyticsManager.recordEventForUserInBackground
  221. ).to.have.been.calledWith(this.user._id, 'project-joined', {
  222. mode: 'edit',
  223. projectId: this.project._id.toString(),
  224. ownerId: this.project.owner_ref.toString(),
  225. role: PrivilegeLevels.READ_AND_WRITE,
  226. source: 'link-sharing',
  227. })
  228. })
  229. it('emits a project membership changed event', function () {
  230. expect(
  231. this.EditorRealTimeController.emitToRoom
  232. ).to.have.been.calledWith(
  233. this.project._id,
  234. 'project:membership:changed',
  235. { members: true, invites: true }
  236. )
  237. })
  238. it('checks token hash', function () {
  239. expect(
  240. this.TokenAccessHandler.checkTokenHashPrefix
  241. ).to.have.been.calledWith(
  242. this.token,
  243. '#prefix',
  244. 'readAndWrite',
  245. this.user._id,
  246. { projectId: this.project._id, action: 'continue' }
  247. )
  248. })
  249. })
  250. describe('when there are no edit collaborator slots available', function () {
  251. beforeEach(function (done) {
  252. this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
  253. false
  254. )
  255. this.req.params = { token: this.token }
  256. this.req.body = {
  257. confirmedByUser: true,
  258. tokenHashPrefix: '#prefix',
  259. }
  260. this.res.callback = done
  261. this.TokenAccessController.grantTokenAccessReadAndWrite(
  262. this.req,
  263. this.res,
  264. done
  265. )
  266. })
  267. it('adds the user as a read only invited member instead (pendingEditor)', function () {
  268. expect(
  269. this.CollaboratorsHandler.promises.addUserIdToProject
  270. ).to.have.been.calledWith(
  271. this.project._id,
  272. undefined,
  273. this.user._id,
  274. PrivilegeLevels.READ_ONLY,
  275. { pendingEditor: true }
  276. )
  277. })
  278. it('writes a project audit log', function () {
  279. expect(
  280. this.ProjectAuditLogHandler.promises.addEntry
  281. ).to.have.been.calledWith(
  282. this.project._id,
  283. 'accept-via-link-sharing',
  284. this.user._id,
  285. this.req.ip,
  286. { privileges: 'readOnly', pendingEditor: true }
  287. )
  288. })
  289. it('records a project-joined event for the user', function () {
  290. expect(
  291. this.AnalyticsManager.recordEventForUserInBackground
  292. ).to.have.been.calledWith(this.user._id, 'project-joined', {
  293. mode: 'view',
  294. projectId: this.project._id.toString(),
  295. pendingEditor: true,
  296. ownerId: this.project.owner_ref.toString(),
  297. role: PrivilegeLevels.READ_ONLY,
  298. source: 'link-sharing',
  299. })
  300. })
  301. it('emits a project membership changed event', function () {
  302. expect(
  303. this.EditorRealTimeController.emitToRoom
  304. ).to.have.been.calledWith(
  305. this.project._id,
  306. 'project:membership:changed',
  307. { members: true, invites: true }
  308. )
  309. })
  310. it('checks token hash', function () {
  311. expect(
  312. this.TokenAccessHandler.checkTokenHashPrefix
  313. ).to.have.been.calledWith(
  314. this.token,
  315. '#prefix',
  316. 'readAndWrite',
  317. this.user._id,
  318. { projectId: this.project._id, action: 'continue' }
  319. )
  320. })
  321. })
  322. describe('when the access was already granted', function () {
  323. beforeEach(function (done) {
  324. this.project.tokenAccessReadAndWrite_refs.push(this.user._id)
  325. this.req.params = { token: this.token }
  326. this.req.body = { confirmedByUser: true }
  327. this.res.callback = done
  328. this.TokenAccessController.grantTokenAccessReadAndWrite(
  329. this.req,
  330. this.res,
  331. done
  332. )
  333. })
  334. it('writes a project audit log', function () {
  335. expect(
  336. this.ProjectAuditLogHandler.promises.addEntry
  337. ).to.have.been.calledWith(
  338. this.project._id,
  339. 'accept-via-link-sharing',
  340. this.user._id,
  341. this.req.ip,
  342. { privileges: 'readAndWrite' }
  343. )
  344. })
  345. it('checks token hash', function () {
  346. expect(
  347. this.TokenAccessHandler.checkTokenHashPrefix
  348. ).to.have.been.calledWith(
  349. this.token,
  350. undefined,
  351. 'readAndWrite',
  352. this.user._id,
  353. { projectId: this.project._id, action: 'continue' }
  354. )
  355. })
  356. })
  357. describe('hash prefix missing in request', function () {
  358. beforeEach(function (done) {
  359. this.req.params = { token: this.token }
  360. this.req.body = { confirmedByUser: true }
  361. this.res.callback = done
  362. this.TokenAccessController.grantTokenAccessReadAndWrite(
  363. this.req,
  364. this.res,
  365. done
  366. )
  367. })
  368. it('adds the user as a read and write invited member', function () {
  369. expect(
  370. this.CollaboratorsHandler.promises.addUserIdToProject
  371. ).to.have.been.calledWith(
  372. this.project._id,
  373. undefined,
  374. this.user._id,
  375. PrivilegeLevels.READ_AND_WRITE
  376. )
  377. })
  378. it('checks the hash prefix', function () {
  379. expect(
  380. this.TokenAccessHandler.checkTokenHashPrefix
  381. ).to.have.been.calledWith(
  382. this.token,
  383. undefined,
  384. 'readAndWrite',
  385. this.user._id,
  386. { projectId: this.project._id, action: 'continue' }
  387. )
  388. })
  389. })
  390. describe('user is owner of project', function () {
  391. beforeEach(function (done) {
  392. this.AuthorizationManager.promises.getPrivilegeLevelForProject.returns(
  393. PrivilegeLevels.OWNER
  394. )
  395. this.req.params = { token: this.token }
  396. this.req.body = {}
  397. this.res.callback = done
  398. this.TokenAccessController.grantTokenAccessReadAndWrite(
  399. this.req,
  400. this.res,
  401. done
  402. )
  403. })
  404. it('checks token hash and includes log data', function () {
  405. expect(
  406. this.TokenAccessHandler.checkTokenHashPrefix
  407. ).to.have.been.calledWith(
  408. this.token,
  409. undefined,
  410. 'readAndWrite',
  411. this.user._id,
  412. {
  413. projectId: this.project._id,
  414. action: 'user already has higher or same privilege',
  415. }
  416. )
  417. })
  418. })
  419. describe('when user is not logged in', function () {
  420. beforeEach(function () {
  421. this.SessionManager.getLoggedInUserId.returns(null)
  422. this.req.params = { token: this.token }
  423. this.req.body = { tokenHashPrefix: '#prefix' }
  424. })
  425. describe('ANONYMOUS_READ_AND_WRITE_ENABLED is undefined', function () {
  426. beforeEach(function (done) {
  427. this.res.callback = done
  428. this.TokenAccessController.grantTokenAccessReadAndWrite(
  429. this.req,
  430. this.res,
  431. done
  432. )
  433. })
  434. it('redirects to restricted', function () {
  435. expect(this.res.json).to.have.been.calledWith({
  436. redirect: '/restricted',
  437. anonWriteAccessDenied: true,
  438. })
  439. })
  440. it('checks the hash prefix and includes log data', function () {
  441. expect(
  442. this.TokenAccessHandler.checkTokenHashPrefix
  443. ).to.have.been.calledWith(
  444. this.token,
  445. '#prefix',
  446. 'readAndWrite',
  447. null,
  448. {
  449. action: 'denied anonymous read-and-write token access',
  450. }
  451. )
  452. })
  453. it('saves redirect URL with URL fragment', function () {
  454. expect(
  455. this.AuthenticationController.setRedirectInSession.lastCall.args[1]
  456. ).to.equal('/#prefix')
  457. })
  458. })
  459. describe('ANONYMOUS_READ_AND_WRITE_ENABLED is true', function () {
  460. beforeEach(function (done) {
  461. this.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = true
  462. this.res.callback = done
  463. this.TokenAccessController.grantTokenAccessReadAndWrite(
  464. this.req,
  465. this.res,
  466. done
  467. )
  468. })
  469. it('redirects to project', function () {
  470. expect(this.res.json).to.have.been.calledWith({
  471. redirect: `/project/${this.project._id}`,
  472. grantAnonymousAccess: 'readAndWrite',
  473. })
  474. })
  475. it('checks the hash prefix and includes log data', function () {
  476. expect(
  477. this.TokenAccessHandler.checkTokenHashPrefix
  478. ).to.have.been.calledWith(
  479. this.token,
  480. '#prefix',
  481. 'readAndWrite',
  482. null,
  483. {
  484. projectId: this.project._id,
  485. action: 'granting read-write anonymous access',
  486. }
  487. )
  488. })
  489. })
  490. })
  491. describe('when Overleaf SaaS', function () {
  492. beforeEach(function () {
  493. this.Settings.overleaf = {}
  494. })
  495. describe('when token is for v1 project', function () {
  496. beforeEach(function (done) {
  497. this.TokenAccessHandler.promises.getProjectByToken.resolves(undefined)
  498. this.TokenAccessHandler.promises.getV1DocInfo.resolves({
  499. exists: true,
  500. has_owner: true,
  501. })
  502. this.req.params = { token: this.token }
  503. this.req.body = { tokenHashPrefix: '#prefix' }
  504. this.res.callback = done
  505. this.TokenAccessController.grantTokenAccessReadAndWrite(
  506. this.req,
  507. this.res,
  508. done
  509. )
  510. })
  511. it('returns v1 import data', function () {
  512. expect(this.res.json).to.have.been.calledWith({
  513. v1Import: {
  514. status: 'canDownloadZip',
  515. projectId: this.token,
  516. hasOwner: true,
  517. name: 'Untitled',
  518. brandInfo: undefined,
  519. },
  520. })
  521. })
  522. it('checks the hash prefix and includes log data', function () {
  523. expect(
  524. this.TokenAccessHandler.checkTokenHashPrefix
  525. ).to.have.been.calledWith(
  526. this.token,
  527. '#prefix',
  528. 'readAndWrite',
  529. this.user._id,
  530. {
  531. action: 'import v1',
  532. }
  533. )
  534. })
  535. })
  536. describe('when token is not for a v1 or v2 project', function () {
  537. beforeEach(function (done) {
  538. this.TokenAccessHandler.promises.getProjectByToken.resolves(undefined)
  539. this.TokenAccessHandler.promises.getV1DocInfo.resolves({
  540. exists: false,
  541. })
  542. this.req.params = { token: this.token }
  543. this.req.body = { tokenHashPrefix: '#prefix' }
  544. this.res.callback = done
  545. this.TokenAccessController.grantTokenAccessReadAndWrite(
  546. this.req,
  547. this.res,
  548. done
  549. )
  550. })
  551. it('returns 404', function () {
  552. expect(this.res.sendStatus).to.have.been.calledWith(404)
  553. })
  554. it('checks the hash prefix and includes log data', function () {
  555. expect(
  556. this.TokenAccessHandler.checkTokenHashPrefix
  557. ).to.have.been.calledWith(
  558. this.token,
  559. '#prefix',
  560. 'readAndWrite',
  561. this.user._id,
  562. {
  563. action: '404',
  564. }
  565. )
  566. })
  567. })
  568. })
  569. describe('not Overleaf SaaS', function () {
  570. beforeEach(function () {
  571. this.TokenAccessHandler.promises.getProjectByToken.resolves(undefined)
  572. this.req.params = { token: this.token }
  573. this.req.body = { tokenHashPrefix: '#prefix' }
  574. })
  575. it('passes Errors.NotFoundError to next when project not found and still checks token hash', function (done) {
  576. this.TokenAccessController.grantTokenAccessReadAndWrite(
  577. this.req,
  578. this.res,
  579. args => {
  580. expect(args).to.be.instanceof(this.Errors.NotFoundError)
  581. expect(
  582. this.TokenAccessHandler.checkTokenHashPrefix
  583. ).to.have.been.calledWith(
  584. this.token,
  585. '#prefix',
  586. 'readAndWrite',
  587. this.user._id,
  588. {
  589. action: '404',
  590. }
  591. )
  592. done()
  593. }
  594. )
  595. })
  596. })
  597. describe('when user is admin', function () {
  598. const admin = { _id: new ObjectId(), isAdmin: true }
  599. beforeEach(function () {
  600. this.SessionManager.getLoggedInUserId.returns(admin._id)
  601. this.SessionManager.getSessionUser.returns(admin)
  602. this.AdminAuthorizationHelper.canRedirectToAdminDomain.returns(true)
  603. this.req.params = { token: this.token }
  604. this.req.body = { confirmedByUser: true, tokenHashPrefix: '#prefix' }
  605. })
  606. it('redirects if project owner is non-admin', function () {
  607. this.UserGetter.promises.getUserConfirmedEmails = sinon
  608. .stub()
  609. .resolves([{ email: 'test@not-overleaf.com' }])
  610. this.res.callback = () => {
  611. expect(this.res.json).to.have.been.calledWith({
  612. redirect: `${this.Settings.adminUrl}/#prefix`,
  613. })
  614. }
  615. this.TokenAccessController.grantTokenAccessReadAndWrite(
  616. this.req,
  617. this.res
  618. )
  619. })
  620. it('grants access if project owner is an internal staff', function () {
  621. const internalStaff = { _id: new ObjectId(), isAdmin: true }
  622. const projectFromInternalStaff = {
  623. _id: new ObjectId(),
  624. name: 'test',
  625. tokenAccessReadAndWrite_refs: [],
  626. tokenAccessReadOnly_refs: [],
  627. owner_ref: internalStaff._id,
  628. }
  629. this.UserGetter.promises.getUser = sinon.stub().resolves(internalStaff)
  630. this.UserGetter.promises.getUserConfirmedEmails = sinon
  631. .stub()
  632. .resolves([{ email: 'test@overleaf.com' }])
  633. this.TokenAccessHandler.promises.getProjectByToken = sinon
  634. .stub()
  635. .resolves(projectFromInternalStaff)
  636. this.res.callback = () => {
  637. expect(
  638. this.CollaboratorsHandler.promises.addUserIdToProject
  639. ).to.have.been.calledWith(
  640. projectFromInternalStaff._id,
  641. undefined,
  642. admin._id,
  643. PrivilegeLevels.READ_AND_WRITE
  644. )
  645. }
  646. this.TokenAccessController.grantTokenAccessReadAndWrite(
  647. this.req,
  648. this.res
  649. )
  650. })
  651. })
  652. it('passes Errors.NotFoundError to next when token access is not enabled but still checks token hash', function (done) {
  653. this.TokenAccessHandler.tokenAccessEnabledForProject.returns(false)
  654. this.req.params = { token: this.token }
  655. this.req.body = { tokenHashPrefix: '#prefix' }
  656. this.TokenAccessController.grantTokenAccessReadAndWrite(
  657. this.req,
  658. this.res,
  659. args => {
  660. expect(args).to.be.instanceof(this.Errors.NotFoundError)
  661. expect(
  662. this.TokenAccessHandler.checkTokenHashPrefix
  663. ).to.have.been.calledWith(
  664. this.token,
  665. '#prefix',
  666. 'readAndWrite',
  667. this.user._id,
  668. {
  669. projectId: this.project._id,
  670. action: 'token access not enabled',
  671. }
  672. )
  673. done()
  674. }
  675. )
  676. })
  677. it('returns 400 when not using a read write token', function () {
  678. this.TokenAccessHandler.isReadAndWriteToken.returns(false)
  679. this.req.params = { token: this.token }
  680. this.req.body = { tokenHashPrefix: '#prefix' }
  681. this.TokenAccessController.grantTokenAccessReadAndWrite(
  682. this.req,
  683. this.res
  684. )
  685. expect(this.res.sendStatus).to.have.been.calledWith(400)
  686. })
  687. })
  688. describe('grantTokenAccessReadOnly', function () {
  689. describe('normal case', function () {
  690. beforeEach(function (done) {
  691. this.req.params = { token: this.token }
  692. this.req.body = { confirmedByUser: true, tokenHashPrefix: '#prefix' }
  693. this.res.callback = done
  694. this.TokenAccessController.grantTokenAccessReadOnly(
  695. this.req,
  696. this.res,
  697. done
  698. )
  699. })
  700. it('grants read-only access', function () {
  701. expect(
  702. this.TokenAccessHandler.promises.addReadOnlyUserToProject
  703. ).to.have.been.calledWith(
  704. this.user._id,
  705. this.project._id,
  706. this.project.owner_ref
  707. )
  708. })
  709. it('writes a project audit log', function () {
  710. expect(
  711. this.ProjectAuditLogHandler.promises.addEntry
  712. ).to.have.been.calledWith(
  713. this.project._id,
  714. 'join-via-token',
  715. this.user._id,
  716. this.req.ip,
  717. { privileges: 'readOnly' }
  718. )
  719. })
  720. it('checks if hash prefix matches', function () {
  721. expect(
  722. this.TokenAccessHandler.checkTokenHashPrefix
  723. ).to.have.been.calledWith(
  724. this.token,
  725. '#prefix',
  726. 'readOnly',
  727. this.user._id,
  728. { projectId: this.project._id, action: 'continue' }
  729. )
  730. })
  731. })
  732. describe('when the access was already granted', function () {
  733. beforeEach(function (done) {
  734. this.project.tokenAccessReadOnly_refs.push(this.user._id)
  735. this.req.params = { token: this.token }
  736. this.req.body = { confirmedByUser: true }
  737. this.res.callback = done
  738. this.TokenAccessController.grantTokenAccessReadOnly(
  739. this.req,
  740. this.res,
  741. done
  742. )
  743. })
  744. it("doesn't write a project audit log", function () {
  745. expect(this.ProjectAuditLogHandler.promises.addEntry).to.not.have.been
  746. .called
  747. })
  748. it('still checks if hash prefix matches', function () {
  749. expect(
  750. this.TokenAccessHandler.checkTokenHashPrefix
  751. ).to.have.been.calledWith(
  752. this.token,
  753. undefined,
  754. 'readOnly',
  755. this.user._id,
  756. { projectId: this.project._id, action: 'continue' }
  757. )
  758. })
  759. })
  760. it('returns 400 when not using a read only token', function () {
  761. this.TokenAccessHandler.isReadOnlyToken.returns(false)
  762. this.req.params = { token: this.token }
  763. this.req.body = { tokenHashPrefix: '#prefix' }
  764. this.TokenAccessController.grantTokenAccessReadOnly(this.req, this.res)
  765. expect(this.res.sendStatus).to.have.been.calledWith(400)
  766. })
  767. describe('anonymous users', function () {
  768. beforeEach(function (done) {
  769. this.req.params = { token: this.token }
  770. this.SessionManager.getLoggedInUserId.returns(null)
  771. this.res.callback = done
  772. this.TokenAccessController.grantTokenAccessReadOnly(
  773. this.req,
  774. this.res,
  775. done
  776. )
  777. })
  778. it('allows anonymous users and checks the token hash', function () {
  779. expect(this.res.json).to.have.been.calledWith({
  780. redirect: `/project/${this.project._id}`,
  781. grantAnonymousAccess: 'readOnly',
  782. })
  783. expect(
  784. this.TokenAccessHandler.checkTokenHashPrefix
  785. ).to.have.been.calledWith(this.token, undefined, 'readOnly', null, {
  786. projectId: this.project._id,
  787. action: 'granting read-only anonymous access',
  788. })
  789. })
  790. })
  791. describe('user is owner of project', function () {
  792. beforeEach(function (done) {
  793. this.AuthorizationManager.promises.getPrivilegeLevelForProject.returns(
  794. PrivilegeLevels.OWNER
  795. )
  796. this.req.params = { token: this.token }
  797. this.req.body = {}
  798. this.res.callback = done
  799. this.TokenAccessController.grantTokenAccessReadOnly(
  800. this.req,
  801. this.res,
  802. done
  803. )
  804. })
  805. it('checks token hash and includes log data', function () {
  806. expect(
  807. this.TokenAccessHandler.checkTokenHashPrefix
  808. ).to.have.been.calledWith(
  809. this.token,
  810. undefined,
  811. 'readOnly',
  812. this.user._id,
  813. {
  814. projectId: this.project._id,
  815. action: 'user already has higher or same privilege',
  816. }
  817. )
  818. })
  819. })
  820. it('passes Errors.NotFoundError to next when token access is not enabled but still checks token hash', function (done) {
  821. this.TokenAccessHandler.tokenAccessEnabledForProject.returns(false)
  822. this.req.params = { token: this.token }
  823. this.req.body = { tokenHashPrefix: '#prefix' }
  824. this.TokenAccessController.grantTokenAccessReadOnly(
  825. this.req,
  826. this.res,
  827. args => {
  828. expect(args).to.be.instanceof(this.Errors.NotFoundError)
  829. expect(
  830. this.TokenAccessHandler.checkTokenHashPrefix
  831. ).to.have.been.calledWith(
  832. this.token,
  833. '#prefix',
  834. 'readOnly',
  835. this.user._id,
  836. {
  837. projectId: this.project._id,
  838. action: 'token access not enabled',
  839. }
  840. )
  841. done()
  842. }
  843. )
  844. })
  845. })
  846. describe('ensureUserCanUseSharingUpdatesConsentPage', function () {
  847. beforeEach(function () {
  848. this.req.params = { Project_id: this.project._id }
  849. })
  850. describe('when not in link sharing changes test', function () {
  851. beforeEach(function (done) {
  852. this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done())
  853. this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
  854. this.req,
  855. this.res,
  856. done
  857. )
  858. })
  859. it('redirects to the project/editor', function () {
  860. expect(this.AsyncFormHelper.redirect).to.have.been.calledWith(
  861. this.req,
  862. this.res,
  863. `/project/${this.project._id}`
  864. )
  865. })
  866. })
  867. describe('when link sharing changes test active', function () {
  868. beforeEach(function () {
  869. this.SplitTestHandler.promises.getAssignmentForUser.resolves({
  870. variant: 'active',
  871. })
  872. })
  873. describe('when user is not an invited editor and is a read write token member', function () {
  874. beforeEach(function (done) {
  875. this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves(
  876. false
  877. )
  878. this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves(
  879. true
  880. )
  881. this.next.callsFake(() => done())
  882. this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
  883. this.req,
  884. this.res,
  885. this.next
  886. )
  887. })
  888. it('calls next', function () {
  889. expect(
  890. this.CollaboratorsGetter.promises
  891. .isUserInvitedReadWriteMemberOfProject
  892. ).to.have.been.calledWith(this.user._id, this.project._id)
  893. expect(
  894. this.CollaboratorsGetter.promises.userIsReadWriteTokenMember
  895. ).to.have.been.calledWith(this.user._id, this.project._id)
  896. expect(this.next).to.have.been.calledOnce
  897. expect(this.next.firstCall.args[0]).to.not.exist
  898. })
  899. })
  900. describe('when user is already an invited editor', function () {
  901. beforeEach(function (done) {
  902. this.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves(
  903. true
  904. )
  905. this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done())
  906. this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
  907. this.req,
  908. this.res,
  909. done
  910. )
  911. })
  912. it('redirects to the project/editor', function () {
  913. expect(this.AsyncFormHelper.redirect).to.have.been.calledWith(
  914. this.req,
  915. this.res,
  916. `/project/${this.project._id}`
  917. )
  918. })
  919. })
  920. describe('when user not a read write token member', function () {
  921. beforeEach(function (done) {
  922. this.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves(
  923. false
  924. )
  925. this.AsyncFormHelper.redirect = sinon.stub().callsFake(() => done())
  926. this.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
  927. this.req,
  928. this.res,
  929. done
  930. )
  931. })
  932. it('redirects to the project/editor', function () {
  933. expect(this.AsyncFormHelper.redirect).to.have.been.calledWith(
  934. this.req,
  935. this.res,
  936. `/project/${this.project._id}`
  937. )
  938. })
  939. })
  940. })
  941. })
  942. describe('moveReadWriteToCollaborators', function () {
  943. beforeEach(function () {
  944. this.req.params = { Project_id: this.project._id }
  945. })
  946. describe('when there are collaborator slots available', function () {
  947. beforeEach(function () {
  948. this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
  949. true
  950. )
  951. })
  952. describe('previously joined token access user moving to named collaborator', function () {
  953. beforeEach(function (done) {
  954. this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
  955. false
  956. )
  957. this.res.callback = done
  958. this.TokenAccessController.moveReadWriteToCollaborators(
  959. this.req,
  960. this.res,
  961. done
  962. )
  963. })
  964. it('sets the privilege level to read and write for the invited viewer', function () {
  965. expect(
  966. this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject
  967. ).to.have.been.calledWith(this.user._id, this.project._id)
  968. expect(
  969. this.CollaboratorsHandler.promises.addUserIdToProject
  970. ).to.have.been.calledWith(
  971. this.project._id,
  972. undefined,
  973. this.user._id,
  974. PrivilegeLevels.READ_AND_WRITE
  975. )
  976. expect(this.res.sendStatus).to.have.been.calledWith(204)
  977. })
  978. })
  979. })
  980. describe('when there are no edit collaborator slots available', function () {
  981. beforeEach(function () {
  982. this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
  983. false
  984. )
  985. })
  986. describe('previously joined token access user moving to named collaborator', function () {
  987. beforeEach(function (done) {
  988. this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
  989. false
  990. )
  991. this.res.callback = done
  992. this.TokenAccessController.moveReadWriteToCollaborators(
  993. this.req,
  994. this.res,
  995. done
  996. )
  997. })
  998. it('sets the privilege level to read only for the invited viewer (pendingEditor)', function () {
  999. expect(
  1000. this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject
  1001. ).to.have.been.calledWith(this.user._id, this.project._id)
  1002. expect(
  1003. this.CollaboratorsHandler.promises.addUserIdToProject
  1004. ).to.have.been.calledWith(
  1005. this.project._id,
  1006. undefined,
  1007. this.user._id,
  1008. PrivilegeLevels.READ_ONLY,
  1009. { pendingEditor: true }
  1010. )
  1011. expect(this.res.sendStatus).to.have.been.calledWith(204)
  1012. })
  1013. })
  1014. })
  1015. })
  1016. describe('moveReadWriteToReadOnly', function () {
  1017. beforeEach(function () {
  1018. this.req.params = { Project_id: this.project._id }
  1019. })
  1020. describe('previously joined token access user moving to anonymous viewer', function () {
  1021. beforeEach(function (done) {
  1022. this.res.callback = done
  1023. this.TokenAccessController.moveReadWriteToReadOnly(
  1024. this.req,
  1025. this.res,
  1026. done
  1027. )
  1028. })
  1029. it('removes them from read write token access refs and adds them to read only token access refs', function () {
  1030. expect(
  1031. this.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly
  1032. ).to.have.been.calledWith(this.user._id, this.project._id)
  1033. expect(this.res.sendStatus).to.have.been.calledWith(204)
  1034. })
  1035. it('writes a project audit log', function () {
  1036. expect(
  1037. this.ProjectAuditLogHandler.promises.addEntry
  1038. ).to.have.been.calledWith(
  1039. this.project._id,
  1040. 'readonly-via-sharing-updates',
  1041. this.user._id,
  1042. this.req.ip
  1043. )
  1044. })
  1045. })
  1046. })
  1047. })