TokenAccessController.test.mjs 39 KB

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