TokenAccessController.test.mjs 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277
  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. await new Promise(resolve => {
  249. ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
  250. true
  251. )
  252. ctx.req.params = { token: ctx.token }
  253. ctx.req.body = {
  254. confirmedByUser: true,
  255. tokenHashPrefix: '#prefix',
  256. }
  257. ctx.res.callback = resolve
  258. ctx.TokenAccessController.grantTokenAccessReadAndWrite(
  259. ctx.req,
  260. ctx.res,
  261. resolve
  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. await new Promise(resolve => {
  319. ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
  320. false
  321. )
  322. ctx.req.params = { token: ctx.token }
  323. ctx.req.body = {
  324. confirmedByUser: true,
  325. tokenHashPrefix: '#prefix',
  326. }
  327. ctx.res.callback = resolve
  328. ctx.TokenAccessController.grantTokenAccessReadAndWrite(
  329. ctx.req,
  330. ctx.res,
  331. resolve
  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. await new Promise(resolve => {
  391. ctx.project.tokenAccessReadAndWrite_refs.push(ctx.user._id)
  392. ctx.req.params = { token: ctx.token }
  393. ctx.req.body = { confirmedByUser: true }
  394. ctx.res.callback = resolve
  395. ctx.TokenAccessController.grantTokenAccessReadAndWrite(
  396. ctx.req,
  397. ctx.res,
  398. resolve
  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. await new Promise(resolve => {
  428. ctx.req.params = { token: ctx.token }
  429. ctx.req.body = { confirmedByUser: true }
  430. ctx.res.callback = resolve
  431. ctx.TokenAccessController.grantTokenAccessReadAndWrite(
  432. ctx.req,
  433. ctx.res,
  434. resolve
  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 => {
  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. resolve
  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 => {
  500. ctx.res.callback = resolve
  501. ctx.TokenAccessController.grantTokenAccessReadAndWrite(
  502. ctx.req,
  503. ctx.res,
  504. resolve
  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. await new Promise(resolve => {
  536. ctx.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = true
  537. ctx.res.callback = resolve
  538. ctx.TokenAccessController.grantTokenAccessReadAndWrite(
  539. ctx.req,
  540. ctx.res,
  541. resolve
  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. await new Promise(resolve => {
  574. ctx.TokenAccessHandler.promises.getProjectByToken.resolves(
  575. undefined
  576. )
  577. ctx.TokenAccessHandler.promises.getV1DocInfo.resolves({
  578. exists: true,
  579. has_owner: true,
  580. })
  581. ctx.req.params = { token: ctx.token }
  582. ctx.req.body = { tokenHashPrefix: '#prefix' }
  583. ctx.res.callback = resolve
  584. ctx.TokenAccessController.grantTokenAccessReadAndWrite(
  585. ctx.req,
  586. ctx.res,
  587. resolve
  588. )
  589. })
  590. })
  591. it('returns v1 import data', function (ctx) {
  592. expect(ctx.res.json).to.have.been.calledWith({
  593. v1Import: {
  594. status: 'canDownloadZip',
  595. projectId: ctx.token,
  596. hasOwner: true,
  597. name: 'Untitled',
  598. brandInfo: undefined,
  599. },
  600. })
  601. })
  602. it('checks the hash prefix and includes log data', function (ctx) {
  603. expect(
  604. ctx.TokenAccessHandler.checkTokenHashPrefix
  605. ).to.have.been.calledWith(
  606. ctx.token,
  607. '#prefix',
  608. 'readAndWrite',
  609. ctx.user._id,
  610. {
  611. action: 'import v1',
  612. }
  613. )
  614. })
  615. })
  616. describe('when token is not for a v1 or v2 project', function () {
  617. beforeEach(async function (ctx) {
  618. await new Promise(resolve => {
  619. ctx.TokenAccessHandler.promises.getProjectByToken.resolves(
  620. undefined
  621. )
  622. ctx.TokenAccessHandler.promises.getV1DocInfo.resolves({
  623. exists: false,
  624. })
  625. ctx.req.params = { token: ctx.token }
  626. ctx.req.body = { tokenHashPrefix: '#prefix' }
  627. ctx.res.callback = resolve
  628. ctx.TokenAccessController.grantTokenAccessReadAndWrite(
  629. ctx.req,
  630. ctx.res,
  631. resolve
  632. )
  633. })
  634. })
  635. it('returns 404', function (ctx) {
  636. expect(ctx.res.sendStatus).to.have.been.calledWith(404)
  637. })
  638. it('checks the hash prefix and includes log data', function (ctx) {
  639. expect(
  640. ctx.TokenAccessHandler.checkTokenHashPrefix
  641. ).to.have.been.calledWith(
  642. ctx.token,
  643. '#prefix',
  644. 'readAndWrite',
  645. ctx.user._id,
  646. {
  647. action: '404',
  648. }
  649. )
  650. })
  651. })
  652. })
  653. describe('not Overleaf SaaS', function () {
  654. beforeEach(function (ctx) {
  655. ctx.TokenAccessHandler.promises.getProjectByToken.resolves(undefined)
  656. ctx.req.params = { token: ctx.token }
  657. ctx.req.body = { tokenHashPrefix: '#prefix' }
  658. })
  659. it('passes Errors.NotFoundError to next when project not found and still checks token hash', async function (ctx) {
  660. await new Promise(resolve => {
  661. ctx.TokenAccessController.grantTokenAccessReadAndWrite(
  662. ctx.req,
  663. ctx.res,
  664. args => {
  665. expect(args).to.be.instanceof(ctx.Errors.NotFoundError)
  666. expect(
  667. ctx.TokenAccessHandler.checkTokenHashPrefix
  668. ).to.have.been.calledWith(
  669. ctx.token,
  670. '#prefix',
  671. 'readAndWrite',
  672. ctx.user._id,
  673. {
  674. action: '404',
  675. }
  676. )
  677. resolve()
  678. }
  679. )
  680. })
  681. })
  682. })
  683. describe('when user is admin', function () {
  684. const admin = { _id: new ObjectId(), isAdmin: true }
  685. beforeEach(function (ctx) {
  686. ctx.SessionManager.getLoggedInUserId.returns(admin._id)
  687. ctx.SessionManager.getSessionUser.returns(admin)
  688. ctx.AdminAuthorizationHelper.canRedirectToAdminDomain.returns(true)
  689. ctx.req.params = { token: ctx.token }
  690. ctx.req.body = { confirmedByUser: true, tokenHashPrefix: '#prefix' }
  691. })
  692. it('redirects if project owner is non-admin', async function (ctx) {
  693. ctx.UserGetter.promises.getUserConfirmedEmails = sinon
  694. .stub()
  695. .resolves([{ email: 'test@not-overleaf.com' }])
  696. await new Promise(resolve => {
  697. ctx.res.callback = () => {
  698. expect(ctx.res.json).to.have.been.calledWith({
  699. redirect: `${ctx.Settings.adminUrl}/#prefix`,
  700. })
  701. resolve()
  702. }
  703. ctx.TokenAccessController.grantTokenAccessReadAndWrite(
  704. ctx.req,
  705. ctx.res
  706. )
  707. })
  708. })
  709. it('grants access if project owner is an internal staff', function (ctx) {
  710. const internalStaff = { _id: new ObjectId(), isAdmin: true }
  711. const projectFromInternalStaff = {
  712. _id: new ObjectId(),
  713. name: 'test',
  714. tokenAccessReadAndWrite_refs: [],
  715. tokenAccessReadOnly_refs: [],
  716. owner_ref: internalStaff._id,
  717. }
  718. ctx.UserGetter.promises.getUser = sinon.stub().resolves(internalStaff)
  719. ctx.UserGetter.promises.getUserConfirmedEmails = sinon
  720. .stub()
  721. .resolves([{ email: 'test@overleaf.com' }])
  722. ctx.TokenAccessHandler.promises.getProjectByToken = sinon
  723. .stub()
  724. .resolves(projectFromInternalStaff)
  725. ctx.res.callback = () => {
  726. expect(
  727. ctx.CollaboratorsHandler.promises.addUserIdToProject
  728. ).to.have.been.calledWith(
  729. projectFromInternalStaff._id,
  730. undefined,
  731. admin._id,
  732. PrivilegeLevels.READ_AND_WRITE
  733. )
  734. }
  735. ctx.TokenAccessController.grantTokenAccessReadAndWrite(ctx.req, ctx.res)
  736. })
  737. })
  738. it('passes Errors.NotFoundError to next when token access is not enabled but still checks token hash', async function (ctx) {
  739. await new Promise(resolve => {
  740. ctx.TokenAccessHandler.tokenAccessEnabledForProject.returns(false)
  741. ctx.req.params = { token: ctx.token }
  742. ctx.req.body = { tokenHashPrefix: '#prefix' }
  743. ctx.TokenAccessController.grantTokenAccessReadAndWrite(
  744. ctx.req,
  745. ctx.res,
  746. args => {
  747. expect(args).to.be.instanceof(ctx.Errors.NotFoundError)
  748. expect(
  749. ctx.TokenAccessHandler.checkTokenHashPrefix
  750. ).to.have.been.calledWith(
  751. ctx.token,
  752. '#prefix',
  753. 'readAndWrite',
  754. ctx.user._id,
  755. {
  756. projectId: ctx.project._id,
  757. action: 'token access not enabled',
  758. }
  759. )
  760. resolve()
  761. }
  762. )
  763. })
  764. })
  765. it('returns 400 when not using a read write token', function (ctx) {
  766. ctx.TokenAccessHandler.isReadAndWriteToken.returns(false)
  767. ctx.req.params = { token: ctx.token }
  768. ctx.req.body = { tokenHashPrefix: '#prefix' }
  769. ctx.TokenAccessController.grantTokenAccessReadAndWrite(ctx.req, ctx.res)
  770. expect(ctx.res.sendStatus).to.have.been.calledWith(400)
  771. })
  772. })
  773. describe('grantTokenAccessReadOnly', function () {
  774. describe('normal case', function () {
  775. beforeEach(async function (ctx) {
  776. await new Promise(resolve => {
  777. ctx.req.params = { token: ctx.token }
  778. ctx.req.body = { confirmedByUser: true, tokenHashPrefix: '#prefix' }
  779. ctx.res.callback = resolve
  780. ctx.TokenAccessController.grantTokenAccessReadOnly(
  781. ctx.req,
  782. ctx.res,
  783. resolve
  784. )
  785. })
  786. })
  787. it('grants read-only access', function (ctx) {
  788. expect(
  789. ctx.TokenAccessHandler.promises.addReadOnlyUserToProject
  790. ).to.have.been.calledWith(
  791. ctx.user._id,
  792. ctx.project._id,
  793. ctx.project.owner_ref
  794. )
  795. })
  796. it('writes a project audit log', function (ctx) {
  797. expect(
  798. ctx.ProjectAuditLogHandler.promises.addEntry
  799. ).to.have.been.calledWith(
  800. ctx.project._id,
  801. 'join-via-token',
  802. ctx.user._id,
  803. ctx.req.ip,
  804. { privileges: 'readOnly' }
  805. )
  806. })
  807. it('checks if hash prefix matches', function (ctx) {
  808. expect(
  809. ctx.TokenAccessHandler.checkTokenHashPrefix
  810. ).to.have.been.calledWith(
  811. ctx.token,
  812. '#prefix',
  813. 'readOnly',
  814. ctx.user._id,
  815. { projectId: ctx.project._id, action: 'continue' }
  816. )
  817. })
  818. })
  819. describe('when the access was already granted', function () {
  820. beforeEach(async function (ctx) {
  821. await new Promise(resolve => {
  822. ctx.project.tokenAccessReadOnly_refs.push(ctx.user._id)
  823. ctx.req.params = { token: ctx.token }
  824. ctx.req.body = { confirmedByUser: true }
  825. ctx.res.callback = resolve
  826. ctx.TokenAccessController.grantTokenAccessReadOnly(
  827. ctx.req,
  828. ctx.res,
  829. resolve
  830. )
  831. })
  832. })
  833. it("doesn't write a project audit log", function (ctx) {
  834. expect(ctx.ProjectAuditLogHandler.promises.addEntry).to.not.have.been
  835. .called
  836. })
  837. it('still checks if hash prefix matches', function (ctx) {
  838. expect(
  839. ctx.TokenAccessHandler.checkTokenHashPrefix
  840. ).to.have.been.calledWith(
  841. ctx.token,
  842. undefined,
  843. 'readOnly',
  844. ctx.user._id,
  845. { projectId: ctx.project._id, action: 'continue' }
  846. )
  847. })
  848. })
  849. it('returns 400 when not using a read only token', function (ctx) {
  850. ctx.TokenAccessHandler.isReadOnlyToken.returns(false)
  851. ctx.req.params = { token: ctx.token }
  852. ctx.req.body = { tokenHashPrefix: '#prefix' }
  853. ctx.TokenAccessController.grantTokenAccessReadOnly(ctx.req, ctx.res)
  854. expect(ctx.res.sendStatus).to.have.been.calledWith(400)
  855. })
  856. describe('anonymous users', function () {
  857. beforeEach(async function (ctx) {
  858. await new Promise(resolve => {
  859. ctx.req.params = { token: ctx.token }
  860. ctx.SessionManager.getLoggedInUserId.returns(null)
  861. ctx.res.callback = resolve
  862. ctx.TokenAccessController.grantTokenAccessReadOnly(
  863. ctx.req,
  864. ctx.res,
  865. resolve
  866. )
  867. })
  868. })
  869. it('allows anonymous users and checks the token hash', function (ctx) {
  870. expect(ctx.res.json).to.have.been.calledWith({
  871. redirect: `/project/${ctx.project._id}`,
  872. grantAnonymousAccess: 'readOnly',
  873. })
  874. expect(
  875. ctx.TokenAccessHandler.checkTokenHashPrefix
  876. ).to.have.been.calledWith(ctx.token, undefined, 'readOnly', null, {
  877. projectId: ctx.project._id,
  878. action: 'granting read-only anonymous access',
  879. })
  880. })
  881. })
  882. describe('user is owner of project', function () {
  883. beforeEach(async function (ctx) {
  884. await new Promise(resolve => {
  885. ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.returns(
  886. PrivilegeLevels.OWNER
  887. )
  888. ctx.req.params = { token: ctx.token }
  889. ctx.req.body = {}
  890. ctx.res.callback = resolve
  891. ctx.TokenAccessController.grantTokenAccessReadOnly(
  892. ctx.req,
  893. ctx.res,
  894. resolve
  895. )
  896. })
  897. })
  898. it('checks token hash and includes log data', function (ctx) {
  899. expect(
  900. ctx.TokenAccessHandler.checkTokenHashPrefix
  901. ).to.have.been.calledWith(
  902. ctx.token,
  903. undefined,
  904. 'readOnly',
  905. ctx.user._id,
  906. {
  907. projectId: ctx.project._id,
  908. action: 'user already has higher or same privilege',
  909. }
  910. )
  911. })
  912. })
  913. it('passes Errors.NotFoundError to next when token access is not enabled but still checks token hash', async function (ctx) {
  914. await new Promise(resolve => {
  915. ctx.TokenAccessHandler.tokenAccessEnabledForProject.returns(false)
  916. ctx.req.params = { token: ctx.token }
  917. ctx.req.body = { tokenHashPrefix: '#prefix' }
  918. ctx.TokenAccessController.grantTokenAccessReadOnly(
  919. ctx.req,
  920. ctx.res,
  921. args => {
  922. expect(args).to.be.instanceof(ctx.Errors.NotFoundError)
  923. expect(
  924. ctx.TokenAccessHandler.checkTokenHashPrefix
  925. ).to.have.been.calledWith(
  926. ctx.token,
  927. '#prefix',
  928. 'readOnly',
  929. ctx.user._id,
  930. {
  931. projectId: ctx.project._id,
  932. action: 'token access not enabled',
  933. }
  934. )
  935. resolve()
  936. }
  937. )
  938. })
  939. })
  940. })
  941. describe('ensureUserCanUseSharingUpdatesConsentPage', function () {
  942. beforeEach(function (ctx) {
  943. ctx.req.params = { Project_id: ctx.project._id }
  944. })
  945. describe('when not in link sharing changes test', function () {
  946. beforeEach(async function (ctx) {
  947. await new Promise(resolve => {
  948. ctx.AsyncFormHelper.redirect = sinon.stub().callsFake(() => resolve())
  949. ctx.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
  950. ctx.req,
  951. ctx.res,
  952. resolve
  953. )
  954. })
  955. })
  956. it('redirects to the project/editor', function (ctx) {
  957. expect(ctx.AsyncFormHelper.redirect).to.have.been.calledWith(
  958. ctx.req,
  959. ctx.res,
  960. `/project/${ctx.project._id}`
  961. )
  962. })
  963. })
  964. describe('when link sharing changes test active', function () {
  965. beforeEach(function (ctx) {
  966. ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({
  967. variant: 'active',
  968. })
  969. })
  970. describe('when user is not an invited editor and is a read write token member', function () {
  971. beforeEach(async function (ctx) {
  972. await new Promise(resolve => {
  973. ctx.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves(
  974. false
  975. )
  976. ctx.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves(
  977. true
  978. )
  979. ctx.next.callsFake(() => resolve())
  980. ctx.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
  981. ctx.req,
  982. ctx.res,
  983. ctx.next
  984. )
  985. })
  986. })
  987. it('calls next', function (ctx) {
  988. expect(
  989. ctx.CollaboratorsGetter.promises
  990. .isUserInvitedReadWriteMemberOfProject
  991. ).to.have.been.calledWith(ctx.user._id, ctx.project._id)
  992. expect(
  993. ctx.CollaboratorsGetter.promises.userIsReadWriteTokenMember
  994. ).to.have.been.calledWith(ctx.user._id, ctx.project._id)
  995. expect(ctx.next).to.have.been.calledOnce
  996. expect(ctx.next.firstCall.args[0]).to.not.exist
  997. })
  998. })
  999. describe('when user is already an invited editor', function () {
  1000. beforeEach(async function (ctx) {
  1001. await new Promise(resolve => {
  1002. ctx.CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject.resolves(
  1003. true
  1004. )
  1005. ctx.AsyncFormHelper.redirect = sinon
  1006. .stub()
  1007. .callsFake(() => resolve())
  1008. ctx.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
  1009. ctx.req,
  1010. ctx.res,
  1011. resolve
  1012. )
  1013. })
  1014. })
  1015. it('redirects to the project/editor', function (ctx) {
  1016. expect(ctx.AsyncFormHelper.redirect).to.have.been.calledWith(
  1017. ctx.req,
  1018. ctx.res,
  1019. `/project/${ctx.project._id}`
  1020. )
  1021. })
  1022. })
  1023. describe('when user not a read write token member', function () {
  1024. beforeEach(async function (ctx) {
  1025. await new Promise(resolve => {
  1026. ctx.CollaboratorsGetter.promises.userIsReadWriteTokenMember.resolves(
  1027. false
  1028. )
  1029. ctx.AsyncFormHelper.redirect = sinon
  1030. .stub()
  1031. .callsFake(() => resolve())
  1032. ctx.TokenAccessController.ensureUserCanUseSharingUpdatesConsentPage(
  1033. ctx.req,
  1034. ctx.res,
  1035. resolve
  1036. )
  1037. })
  1038. })
  1039. it('redirects to the project/editor', function (ctx) {
  1040. expect(ctx.AsyncFormHelper.redirect).to.have.been.calledWith(
  1041. ctx.req,
  1042. ctx.res,
  1043. `/project/${ctx.project._id}`
  1044. )
  1045. })
  1046. })
  1047. })
  1048. })
  1049. describe('moveReadWriteToCollaborators', function () {
  1050. beforeEach(function (ctx) {
  1051. ctx.req.params = { Project_id: ctx.project._id }
  1052. })
  1053. describe('when there are collaborator slots available', function () {
  1054. beforeEach(function (ctx) {
  1055. ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
  1056. true
  1057. )
  1058. })
  1059. describe('previously joined token access user moving to named collaborator', function () {
  1060. beforeEach(async function (ctx) {
  1061. await new Promise(resolve => {
  1062. ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
  1063. false
  1064. )
  1065. ctx.res.callback = resolve
  1066. ctx.TokenAccessController.moveReadWriteToCollaborators(
  1067. ctx.req,
  1068. ctx.res,
  1069. resolve
  1070. )
  1071. })
  1072. })
  1073. it('sets the privilege level to read and write for the invited viewer', function (ctx) {
  1074. expect(
  1075. ctx.TokenAccessHandler.promises.removeReadAndWriteUserFromProject
  1076. ).to.have.been.calledWith(ctx.user._id, ctx.project._id)
  1077. expect(
  1078. ctx.CollaboratorsHandler.promises.addUserIdToProject
  1079. ).to.have.been.calledWith(
  1080. ctx.project._id,
  1081. undefined,
  1082. ctx.user._id,
  1083. PrivilegeLevels.READ_AND_WRITE
  1084. )
  1085. expect(ctx.res.sendStatus).to.have.been.calledWith(204)
  1086. })
  1087. })
  1088. })
  1089. describe('when there are no edit collaborator slots available', function () {
  1090. beforeEach(function (ctx) {
  1091. ctx.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves(
  1092. false
  1093. )
  1094. })
  1095. describe('previously joined token access user moving to named collaborator', function () {
  1096. beforeEach(async function (ctx) {
  1097. await new Promise(resolve => {
  1098. ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
  1099. false
  1100. )
  1101. ctx.res.callback = resolve
  1102. ctx.TokenAccessController.moveReadWriteToCollaborators(
  1103. ctx.req,
  1104. ctx.res,
  1105. resolve
  1106. )
  1107. })
  1108. })
  1109. it('sets the privilege level to read only for the invited viewer (pendingEditor)', function (ctx) {
  1110. expect(
  1111. ctx.TokenAccessHandler.promises.removeReadAndWriteUserFromProject
  1112. ).to.have.been.calledWith(ctx.user._id, ctx.project._id)
  1113. expect(
  1114. ctx.CollaboratorsHandler.promises.addUserIdToProject
  1115. ).to.have.been.calledWith(
  1116. ctx.project._id,
  1117. undefined,
  1118. ctx.user._id,
  1119. PrivilegeLevels.READ_ONLY,
  1120. { pendingEditor: true }
  1121. )
  1122. expect(ctx.res.sendStatus).to.have.been.calledWith(204)
  1123. })
  1124. })
  1125. })
  1126. })
  1127. describe('moveReadWriteToReadOnly', function () {
  1128. beforeEach(function (ctx) {
  1129. ctx.req.params = { Project_id: ctx.project._id }
  1130. })
  1131. describe('previously joined token access user moving to anonymous viewer', function () {
  1132. beforeEach(async function (ctx) {
  1133. await new Promise(resolve => {
  1134. ctx.res.callback = resolve
  1135. ctx.TokenAccessController.moveReadWriteToReadOnly(
  1136. ctx.req,
  1137. ctx.res,
  1138. resolve
  1139. )
  1140. })
  1141. })
  1142. it('removes them from read write token access refs and adds them to read only token access refs', function (ctx) {
  1143. expect(
  1144. ctx.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly
  1145. ).to.have.been.calledWith(ctx.user._id, ctx.project._id)
  1146. expect(ctx.res.sendStatus).to.have.been.calledWith(204)
  1147. })
  1148. it('writes a project audit log', function (ctx) {
  1149. expect(
  1150. ctx.ProjectAuditLogHandler.promises.addEntry
  1151. ).to.have.been.calledWith(
  1152. ctx.project._id,
  1153. 'readonly-via-sharing-updates',
  1154. ctx.user._id,
  1155. ctx.req.ip
  1156. )
  1157. })
  1158. })
  1159. })
  1160. })