TokenAccessHandler.test.mjs 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950
  1. import { vi, expect } from 'vitest'
  2. import path from 'node:path'
  3. import sinon from 'sinon'
  4. import mongodb from 'mongodb-legacy'
  5. import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs'
  6. const modulePath = path.join(
  7. import.meta.dirname,
  8. '../../../../app/src/Features/TokenAccess/TokenAccessHandler'
  9. )
  10. vi.mock('node:crypto', async () => {
  11. const originalModule = await vi.importActual('node:crypto')
  12. return {
  13. default: {
  14. ...originalModule,
  15. timingSafeEqual: vi.fn(originalModule.default.timingSafeEqual),
  16. },
  17. }
  18. })
  19. const { ObjectId } = mongodb
  20. describe('TokenAccessHandler', function () {
  21. beforeEach(async function (ctx) {
  22. ctx.token = 'abcdefabcdef'
  23. ctx.projectId = new ObjectId()
  24. ctx.project = {
  25. _id: ctx.projectId,
  26. publicAccesLevel: 'tokenBased',
  27. owner_ref: new ObjectId(),
  28. }
  29. ctx.userId = new ObjectId()
  30. ctx.req = {}
  31. vi.doMock('mongodb-legacy', () => ({
  32. default: { ObjectId },
  33. }))
  34. vi.doMock('../../../../app/src/models/Project', () => ({
  35. Project: (ctx.Project = {}),
  36. }))
  37. vi.doMock('@overleaf/metrics', () => ({
  38. default: (ctx.Metrics = { inc: sinon.stub() }),
  39. }))
  40. vi.doMock('@overleaf/settings', () => ({
  41. default: (ctx.settings = { disableLinkSharing: false }),
  42. }))
  43. vi.doMock('../../../../app/src/Features/V1/V1Api', () => ({
  44. default: (ctx.V1Api = {
  45. promises: {
  46. request: sinon.stub(),
  47. },
  48. }),
  49. }))
  50. ctx.Crypto = (await vi.importMock('node:crypto')).default
  51. vi.doMock(
  52. '../../../../app/src/Features/Analytics/AnalyticsManager',
  53. () => ({
  54. default: (ctx.Analytics = {
  55. recordEventForUserInBackground: sinon.stub(),
  56. }),
  57. })
  58. )
  59. vi.doMock('../../../../app/src/infrastructure/Features', () => ({
  60. default: (ctx.Features = {}),
  61. }))
  62. ctx.TokenAccessHandler = (await import(modulePath)).default
  63. })
  64. describe('when link sharing is enabled', function () {
  65. beforeEach(function (ctx) {
  66. ctx.Features.hasFeature = sinon
  67. .stub()
  68. .withArgs('link-sharing')
  69. .returns(true)
  70. })
  71. describe('getTokenType', function () {
  72. it('should determine tokens correctly', function (ctx) {
  73. const specs = {
  74. abcdefabcdef: 'readOnly',
  75. aaaaaabbbbbb: 'readOnly',
  76. '54325aaaaaa': 'readAndWrite',
  77. '54325aaaaaabbbbbb': 'readAndWrite',
  78. '': null,
  79. abc123def: null,
  80. }
  81. for (const token of Object.keys(specs)) {
  82. expect(ctx.TokenAccessHandler.getTokenType(token)).to.equal(
  83. specs[token]
  84. )
  85. }
  86. })
  87. })
  88. describe('getProjectByReadOnlyToken', function () {
  89. beforeEach(function (ctx) {
  90. ctx.token = 'abcdefabcdef'
  91. ctx.Project.findOne = sinon.stub().returns({
  92. exec: sinon.stub().resolves(ctx.project),
  93. })
  94. })
  95. it('should get the project', async function (ctx) {
  96. const project =
  97. await ctx.TokenAccessHandler.promises.getProjectByReadOnlyToken(
  98. ctx.token
  99. )
  100. expect(project).to.exist
  101. expect(ctx.Project.findOne.callCount).to.equal(1)
  102. })
  103. })
  104. describe('getProjectByReadAndWriteToken', function () {
  105. beforeEach(function (ctx) {
  106. ctx.token = '1234abcdefabcdef'
  107. ctx.project.tokens = {
  108. readAndWrite: ctx.token,
  109. readAndWritePrefix: '1234',
  110. }
  111. ctx.Project.findOne = sinon.stub().returns({
  112. exec: sinon.stub().resolves(ctx.project),
  113. })
  114. })
  115. it('should get the project and do timing-safe comparison', async function (ctx) {
  116. const project =
  117. await ctx.TokenAccessHandler.promises.getProjectByReadAndWriteToken(
  118. ctx.token
  119. )
  120. expect(project).to.exist
  121. expect(ctx.Crypto.timingSafeEqual).toHaveBeenCalledTimes(1)
  122. expect(
  123. ctx.Crypto.timingSafeEqual.mock.calls[0][0].equals(
  124. Buffer.from(ctx.token)
  125. )
  126. ).toBeTruthy()
  127. expect(ctx.Project.findOne.callCount).to.equal(1)
  128. })
  129. })
  130. describe('addReadOnlyUserToProject', function () {
  131. beforeEach(function (ctx) {
  132. ctx.Project.updateOne = sinon.stub().returns({
  133. exec: sinon.stub().resolves(null),
  134. })
  135. })
  136. it('should call Project.updateOne', async function (ctx) {
  137. await ctx.TokenAccessHandler.promises.addReadOnlyUserToProject(
  138. ctx.userId,
  139. ctx.projectId,
  140. ctx.project.owner_ref
  141. )
  142. expect(ctx.Project.updateOne.callCount).to.equal(1)
  143. expect(
  144. ctx.Project.updateOne.calledWith({
  145. _id: ctx.projectId,
  146. })
  147. ).to.equal(true)
  148. expect(ctx.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys(
  149. 'tokenAccessReadOnly_refs'
  150. )
  151. sinon.assert.calledWith(
  152. ctx.Analytics.recordEventForUserInBackground,
  153. ctx.userId,
  154. 'project-joined',
  155. {
  156. mode: 'view',
  157. role: PrivilegeLevels.READ_ONLY,
  158. projectId: ctx.projectId.toString(),
  159. ownerId: ctx.project.owner_ref.toString(),
  160. source: 'link-sharing',
  161. }
  162. )
  163. })
  164. describe('when Project.updateOne produces an error', function () {
  165. beforeEach(function (ctx) {
  166. ctx.Project.updateOne = sinon
  167. .stub()
  168. .returns({ exec: sinon.stub().rejects(new Error('woops')) })
  169. })
  170. it('should be rejected', async function (ctx) {
  171. await expect(
  172. ctx.TokenAccessHandler.promises.addReadOnlyUserToProject(
  173. ctx.userId,
  174. ctx.projectId
  175. )
  176. ).to.be.rejected
  177. })
  178. })
  179. })
  180. describe('removeReadAndWriteUserFromProject', function () {
  181. beforeEach(function (ctx) {
  182. ctx.Project.updateOne = sinon
  183. .stub()
  184. .returns({ exec: sinon.stub().resolves(null) })
  185. })
  186. it('should call Project.updateOne', async function (ctx) {
  187. await ctx.TokenAccessHandler.promises.removeReadAndWriteUserFromProject(
  188. ctx.userId,
  189. ctx.projectId
  190. )
  191. expect(ctx.Project.updateOne.callCount).to.equal(1)
  192. expect(
  193. ctx.Project.updateOne.calledWith({
  194. _id: ctx.projectId,
  195. })
  196. ).to.equal(true)
  197. expect(ctx.Project.updateOne.lastCall.args[1].$pull).to.have.keys(
  198. 'tokenAccessReadAndWrite_refs'
  199. )
  200. })
  201. })
  202. describe('moveReadAndWriteUserToReadOnly', function () {
  203. beforeEach(function (ctx) {
  204. ctx.Project.updateOne = sinon
  205. .stub()
  206. .returns({ exec: sinon.stub().resolves(null) })
  207. })
  208. it('should call Project.updateOne', async function (ctx) {
  209. await ctx.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly(
  210. ctx.userId,
  211. ctx.projectId
  212. )
  213. expect(ctx.Project.updateOne.callCount).to.equal(1)
  214. expect(
  215. ctx.Project.updateOne.calledWith({
  216. _id: ctx.projectId,
  217. })
  218. ).to.equal(true)
  219. expect(ctx.Project.updateOne.lastCall.args[1].$pull).to.have.keys(
  220. 'tokenAccessReadAndWrite_refs'
  221. )
  222. expect(ctx.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys(
  223. 'tokenAccessReadOnly_refs'
  224. )
  225. })
  226. })
  227. describe('grantSessionTokenAccess', function () {
  228. beforeEach(function (ctx) {
  229. ctx.req = { session: {}, headers: {} }
  230. })
  231. it('should add the token to the session', function (ctx) {
  232. ctx.TokenAccessHandler.promises.grantSessionTokenAccess(
  233. ctx.req,
  234. ctx.projectId,
  235. ctx.token
  236. )
  237. expect(
  238. ctx.req.session.anonTokenAccess[ctx.projectId.toString()]
  239. ).to.equal(ctx.token)
  240. })
  241. })
  242. describe('validateTokenForAnonymousAccess', function () {
  243. describe('when a read-only project is found', function () {
  244. beforeEach(function (ctx) {
  245. ctx.TokenAccessHandler.getTokenType = sinon.stub().returns('readOnly')
  246. ctx.TokenAccessHandler.promises.getProjectByToken = sinon
  247. .stub()
  248. .resolves(ctx.project)
  249. })
  250. it('should try to find projects with both kinds of token', async function (ctx) {
  251. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  252. ctx.projectId,
  253. ctx.token
  254. )
  255. expect(
  256. ctx.TokenAccessHandler.promises.getProjectByToken.callCount
  257. ).to.equal(1)
  258. })
  259. it('should allow read-only access', async function (ctx) {
  260. const { isValidReadAndWrite, isValidReadOnly } =
  261. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  262. ctx.projectId,
  263. ctx.token
  264. )
  265. expect(isValidReadAndWrite).to.equal(false)
  266. expect(isValidReadOnly).to.equal(true)
  267. })
  268. })
  269. describe('when a read-and-write project is found', function () {
  270. beforeEach(function (ctx) {
  271. ctx.TokenAccessHandler.promises.getTokenType = sinon
  272. .stub()
  273. .returns('readAndWrite')
  274. ctx.TokenAccessHandler.promises.getProjectByToken = sinon
  275. .stub()
  276. .resolves(ctx.project)
  277. })
  278. describe('when Anonymous token access is not enabled', function () {
  279. beforeEach(function (ctx) {
  280. ctx.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false
  281. })
  282. it('should try to find projects with both kinds of token', async function (ctx) {
  283. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  284. ctx.projectId,
  285. ctx.token
  286. )
  287. expect(
  288. ctx.TokenAccessHandler.promises.getProjectByToken.callCount
  289. ).to.equal(1)
  290. })
  291. it('should not allow read-and-write access', async function (ctx) {
  292. const { isValidReadAndWrite, isValidReadOnly } =
  293. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  294. ctx.projectId,
  295. ctx.token
  296. )
  297. expect(isValidReadAndWrite).to.equal(false)
  298. expect(isValidReadOnly).to.equal(false)
  299. })
  300. })
  301. describe('when anonymous token access is enabled', function () {
  302. beforeEach(function (ctx) {
  303. ctx.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true
  304. })
  305. it('should try to find projects with both kinds of token', async function (ctx) {
  306. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  307. ctx.projectId,
  308. ctx.token
  309. )
  310. expect(
  311. ctx.TokenAccessHandler.promises.getProjectByToken.callCount
  312. ).to.equal(1)
  313. })
  314. it('should allow read-and-write access', async function (ctx) {
  315. const { isValidReadAndWrite, isValidReadOnly } =
  316. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  317. ctx.projectId,
  318. ctx.token
  319. )
  320. expect(isValidReadAndWrite).to.equal(true)
  321. expect(isValidReadOnly).to.equal(false)
  322. })
  323. })
  324. })
  325. describe('when no project is found', function () {
  326. beforeEach(function (ctx) {
  327. ctx.TokenAccessHandler.promises.getProjectByToken = sinon
  328. .stub()
  329. .resolves(null)
  330. })
  331. it('should try to find projects with both kinds of token', async function (ctx) {
  332. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  333. ctx.projectId,
  334. ctx.token
  335. )
  336. expect(
  337. ctx.TokenAccessHandler.promises.getProjectByToken.callCount
  338. ).to.equal(1)
  339. })
  340. it('should not allow any access', async function (ctx) {
  341. const { isValidReadAndWrite, isValidReadOnly } =
  342. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  343. ctx.projectId,
  344. ctx.token
  345. )
  346. expect(isValidReadAndWrite).to.equal(false)
  347. expect(isValidReadOnly).to.equal(false)
  348. })
  349. })
  350. describe('when findProject produces an error', function () {
  351. beforeEach(function (ctx) {
  352. ctx.TokenAccessHandler.promises.getProjectByToken = sinon
  353. .stub()
  354. .rejects(new Error('woops'))
  355. })
  356. it('should try to find projects with both kinds of token', async function (ctx) {
  357. await expect(
  358. ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  359. ctx.projectId,
  360. ctx.token
  361. )
  362. ).to.be.rejected
  363. expect(
  364. ctx.TokenAccessHandler.promises.getProjectByToken.callCount
  365. ).to.equal(1)
  366. })
  367. it('should produce an error and not allow access', async function (ctx) {
  368. await expect(
  369. ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  370. ctx.projectId,
  371. ctx.token
  372. )
  373. ).to.be.rejected
  374. })
  375. })
  376. describe('when project is not set to token-based access', function () {
  377. beforeEach(function (ctx) {
  378. ctx.project.publicAccesLevel = 'private'
  379. })
  380. describe('for read-and-write project', function () {
  381. beforeEach(function (ctx) {
  382. ctx.TokenAccessHandler.getTokenType = sinon
  383. .stub()
  384. .returns('readAndWrite')
  385. ctx.TokenAccessHandler.promises.getProjectByToken = sinon
  386. .stub()
  387. .resolves(ctx.project)
  388. })
  389. it('should not allow any access', async function (ctx) {
  390. const { isValidReadAndWrite, isValidReadOnly } =
  391. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  392. ctx.projectId,
  393. ctx.token
  394. )
  395. expect(isValidReadAndWrite).to.equal(false)
  396. expect(isValidReadOnly).to.equal(false)
  397. })
  398. })
  399. describe('for read-only project', function () {
  400. beforeEach(function (ctx) {
  401. ctx.TokenAccessHandler.getTokenType = sinon
  402. .stub()
  403. .returns('readOnly')
  404. ctx.TokenAccessHandler.promises.getProjectByToken = sinon
  405. .stub()
  406. .resolves(ctx.project)
  407. })
  408. it('should not allow any access', async function (ctx) {
  409. const { isValidReadAndWrite, isValidReadOnly } =
  410. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  411. ctx.projectId,
  412. ctx.token
  413. )
  414. expect(isValidReadAndWrite).to.equal(false)
  415. expect(isValidReadOnly).to.equal(false)
  416. })
  417. })
  418. describe('with nothing', function () {
  419. beforeEach(function (ctx) {
  420. ctx.TokenAccessHandler.promises.getProjectByToken = sinon
  421. .stub()
  422. .resolves(null)
  423. })
  424. it('should not allow any access', async function (ctx) {
  425. const { isValidReadAndWrite, isValidReadOnly } =
  426. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  427. ctx.projectId,
  428. ctx.token
  429. )
  430. expect(isValidReadAndWrite).to.equal(false)
  431. expect(isValidReadOnly).to.equal(false)
  432. })
  433. })
  434. })
  435. })
  436. })
  437. describe('when link sharing is disabled', function () {
  438. beforeEach(function (ctx) {
  439. ctx.Features.hasFeature = sinon
  440. .stub()
  441. .withArgs('link-sharing')
  442. .returns(false)
  443. })
  444. describe('addReadOnlyUserToProject', function () {
  445. beforeEach(function (ctx) {
  446. ctx.Project.updateOne = sinon.stub().returns({
  447. exec: sinon.stub().resolves(null),
  448. })
  449. })
  450. it('should throw an error', async function (ctx) {
  451. await expect(
  452. ctx.TokenAccessHandler.promises.addReadOnlyUserToProject(
  453. ctx.userId,
  454. ctx.projectId,
  455. ctx.project.owner_ref
  456. )
  457. ).to.be.rejectedWith('link sharing is disabled')
  458. expect(ctx.Project.updateOne.callCount).to.equal(0)
  459. })
  460. })
  461. describe('grantSessionTokenAccess', function () {
  462. beforeEach(function (ctx) {
  463. ctx.req = { session: {}, headers: {} }
  464. })
  465. it('should throw an error', function (ctx) {
  466. expect(() => {
  467. ctx.TokenAccessHandler.promises.grantSessionTokenAccess(
  468. ctx.req,
  469. ctx.projectId,
  470. ctx.token
  471. )
  472. }).to.throw('link sharing is disabled')
  473. expect(ctx.req.session.anonTokenAccess).to.be.undefined
  474. })
  475. })
  476. describe('validateTokenForAnonymousAccess', function () {
  477. describe('when a read-only project is found', function () {
  478. beforeEach(function (ctx) {
  479. ctx.TokenAccessHandler.getTokenType = sinon.stub().returns('readOnly')
  480. ctx.TokenAccessHandler.promises.getProjectByToken = sinon
  481. .stub()
  482. .resolves(ctx.project)
  483. })
  484. it('should refuse access', async function (ctx) {
  485. const { isValidReadAndWrite, isValidReadOnly } =
  486. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  487. ctx.projectId,
  488. ctx.token
  489. )
  490. expect(isValidReadAndWrite).to.equal(false)
  491. expect(isValidReadOnly).to.equal(false)
  492. })
  493. })
  494. describe('when a read-and-write project is found', function () {
  495. beforeEach(function (ctx) {
  496. ctx.TokenAccessHandler.promises.getTokenType = sinon
  497. .stub()
  498. .returns('readAndWrite')
  499. ctx.TokenAccessHandler.promises.getProjectByToken = sinon
  500. .stub()
  501. .resolves(ctx.project)
  502. })
  503. describe('when Anonymous token access is not enabled', function () {
  504. beforeEach(function (ctx) {
  505. ctx.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false
  506. })
  507. it('should refuse access', async function (ctx) {
  508. const { isValidReadAndWrite, isValidReadOnly } =
  509. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  510. ctx.projectId,
  511. ctx.token
  512. )
  513. expect(isValidReadAndWrite).to.equal(false)
  514. expect(isValidReadOnly).to.equal(false)
  515. })
  516. })
  517. describe('when anonymous token access is enabled', function () {
  518. beforeEach(function (ctx) {
  519. ctx.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true
  520. })
  521. it('should not try to find any projects', async function (ctx) {
  522. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  523. ctx.projectId,
  524. ctx.token
  525. )
  526. expect(
  527. ctx.TokenAccessHandler.promises.getProjectByToken.callCount
  528. ).to.equal(0)
  529. })
  530. it('should refuse access', async function (ctx) {
  531. const { isValidReadAndWrite, isValidReadOnly } =
  532. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  533. ctx.projectId,
  534. ctx.token
  535. )
  536. expect(isValidReadAndWrite).to.equal(false)
  537. expect(isValidReadOnly).to.equal(false)
  538. })
  539. })
  540. })
  541. describe('when no project is found', function () {
  542. beforeEach(function (ctx) {
  543. ctx.TokenAccessHandler.promises.getProjectByToken = sinon
  544. .stub()
  545. .resolves(null)
  546. })
  547. it('should not try to find any projects ', async function (ctx) {
  548. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  549. ctx.projectId,
  550. ctx.token
  551. )
  552. expect(
  553. ctx.TokenAccessHandler.promises.getProjectByToken.callCount
  554. ).to.equal(0)
  555. })
  556. it('should not allow any access', async function (ctx) {
  557. const { isValidReadAndWrite, isValidReadOnly } =
  558. await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
  559. ctx.projectId,
  560. ctx.token
  561. )
  562. expect(isValidReadAndWrite).to.equal(false)
  563. expect(isValidReadOnly).to.equal(false)
  564. })
  565. })
  566. })
  567. })
  568. describe('getDocPublishedInfo', function () {
  569. describe('when v1 api not set', function () {
  570. beforeEach(function (ctx) {
  571. ctx.settings.apis = { v1: undefined }
  572. })
  573. it('should not check access and return default info', async function (ctx) {
  574. const info =
  575. await ctx.TokenAccessHandler.promises.getV1DocPublishedInfo(ctx.token)
  576. expect(ctx.V1Api.promises.request.called).to.equal(false)
  577. expect(info).to.deep.equal({
  578. allow: true,
  579. })
  580. })
  581. })
  582. describe('when v1 api is set', function () {
  583. beforeEach(function (ctx) {
  584. ctx.settings.apis = { v1: { url: 'v1Url' } }
  585. })
  586. describe('on V1Api.request success', function () {
  587. beforeEach(function (ctx) {
  588. ctx.V1Api.promises.request = sinon
  589. .stub()
  590. .resolves({ body: 'mock-data' })
  591. })
  592. it('should return response body', async function (ctx) {
  593. const info =
  594. await ctx.TokenAccessHandler.promises.getV1DocPublishedInfo(
  595. ctx.token
  596. )
  597. expect(
  598. ctx.V1Api.promises.request.calledWith({
  599. url: `/api/v1/overleaf/docs/${ctx.token}/is_published`,
  600. })
  601. ).to.equal(true)
  602. expect(info).to.equal('mock-data')
  603. })
  604. })
  605. describe('on V1Api.request error', function () {
  606. beforeEach(function (ctx) {
  607. ctx.V1Api.promises.request = sinon.stub().rejects('error')
  608. })
  609. it('should be rejected', async function (ctx) {
  610. await expect(
  611. ctx.TokenAccessHandler.promises.getV1DocPublishedInfo(ctx.token)
  612. ).to.be.rejected
  613. })
  614. })
  615. })
  616. })
  617. describe('getV1DocInfo', function () {
  618. describe('when v1 api not set', function () {
  619. it('should not check access and return default info', async function (ctx) {
  620. const info = await ctx.TokenAccessHandler.promises.getV1DocInfo(
  621. ctx.token,
  622. ctx.v2UserId
  623. )
  624. expect(ctx.V1Api.promises.request.called).to.equal(false)
  625. expect(info).to.deep.equal({
  626. exists: true,
  627. exported: false,
  628. })
  629. })
  630. })
  631. describe('when v1 api is set', function () {
  632. beforeEach(function (ctx) {
  633. ctx.settings.apis = { v1: 'v1' }
  634. })
  635. describe('on V1Api.request success', function () {
  636. beforeEach(function (ctx) {
  637. ctx.V1Api.promises.request = sinon
  638. .stub()
  639. .resolves({ body: 'mock-data' })
  640. })
  641. it('should return response body', async function (ctx) {
  642. const info = await ctx.TokenAccessHandler.promises.getV1DocInfo(
  643. ctx.token,
  644. ctx.v2UserId
  645. )
  646. expect(
  647. ctx.V1Api.promises.request.calledWith({
  648. url: `/api/v1/overleaf/docs/${ctx.token}/info`,
  649. })
  650. ).to.equal(true)
  651. expect(info).to.equal('mock-data')
  652. })
  653. })
  654. describe('on V1Api.request error', function () {
  655. beforeEach(function (ctx) {
  656. ctx.V1Api.promises.request = sinon.stub().rejects('error')
  657. })
  658. it('should be rejected', async function (ctx) {
  659. await expect(
  660. ctx.TokenAccessHandler.promises.getV1DocInfo(
  661. ctx.token,
  662. ctx.v2UserId
  663. )
  664. ).to.be.rejected
  665. })
  666. })
  667. })
  668. })
  669. describe('createTokenHashPrefix', function () {
  670. it('creates a prefix of the hash', function (ctx) {
  671. const prefix =
  672. ctx.TokenAccessHandler.createTokenHashPrefix('zxpxjrwdtsgd')
  673. expect(prefix.length).to.equal(6)
  674. })
  675. })
  676. describe('normalizeTokenHashPrefix', function () {
  677. const cases = {
  678. // hex string
  679. ab2345: 'ab2345',
  680. '01234f': '01234f',
  681. '012345': '012345',
  682. // remove (encoded) hash
  683. '#012345': '012345',
  684. '%23012345': '012345',
  685. // remove trailing special characters
  686. '012345.': '012345',
  687. '012345/': '012345',
  688. // v1 doc
  689. '%2F1234567%2F': '%2F1234567%2F',
  690. }
  691. for (const [input, output] of Object.entries(cases)) {
  692. it(`should handle ${JSON.stringify(input)}`, function (ctx) {
  693. expect(ctx.TokenAccessHandler.normalizeTokenHashPrefix(input)).to.equal(
  694. output
  695. )
  696. })
  697. }
  698. })
  699. describe('checkTokenHashPrefix', function () {
  700. const userId = 'abc123'
  701. const projectId = 'def456'
  702. it('sends "match" to metrics when prefix matches the prefix of the hash of the token', function (ctx) {
  703. const token = 'zxpxjrwdtsgd'
  704. const prefix = ctx.TokenAccessHandler.createTokenHashPrefix(token)
  705. ctx.TokenAccessHandler.checkTokenHashPrefix(
  706. token,
  707. `#${prefix}`,
  708. 'readOnly',
  709. userId,
  710. { projectId }
  711. )
  712. expect(ctx.Metrics.inc).to.have.been.calledWith(
  713. 'link-sharing.hash-check',
  714. {
  715. path: 'readOnly',
  716. status: 'match',
  717. }
  718. )
  719. })
  720. it('sends "mismatch" to metrics when prefix does not match the prefix of the hash of the token', function (ctx) {
  721. const token = 'zxpxjrwdtsgd'
  722. const prefix = ctx.TokenAccessHandler.createTokenHashPrefix(token)
  723. ctx.TokenAccessHandler.checkTokenHashPrefix(
  724. 'anothertoken',
  725. `#${prefix}`,
  726. 'readOnly',
  727. userId,
  728. { projectId }
  729. )
  730. expect(ctx.Metrics.inc).to.have.been.calledWith(
  731. 'link-sharing.hash-check',
  732. {
  733. path: 'readOnly',
  734. status: 'mismatch',
  735. }
  736. )
  737. expect(ctx.logger.info).toHaveBeenCalledWith(
  738. {
  739. tokenHashPrefix: prefix,
  740. hashPrefixStatus: 'mismatch',
  741. userId,
  742. projectId,
  743. type: 'readOnly',
  744. },
  745. 'mismatched token hash prefix'
  746. )
  747. })
  748. it('sends "missing" to metrics when prefix is undefined', function (ctx) {
  749. ctx.TokenAccessHandler.checkTokenHashPrefix(
  750. 'anothertoken',
  751. undefined,
  752. 'readOnly',
  753. userId,
  754. { projectId }
  755. )
  756. expect(ctx.Metrics.inc).to.have.been.calledWith(
  757. 'link-sharing.hash-check',
  758. {
  759. path: 'readOnly',
  760. status: 'missing',
  761. }
  762. )
  763. })
  764. it('sends "missing" to metrics when URL hash is sent as "#" only', function (ctx) {
  765. ctx.TokenAccessHandler.checkTokenHashPrefix(
  766. 'anothertoken',
  767. '#',
  768. 'readOnly',
  769. userId,
  770. { projectId }
  771. )
  772. expect(ctx.Metrics.inc).to.have.been.calledWith(
  773. 'link-sharing.hash-check',
  774. {
  775. path: 'readOnly',
  776. status: 'missing',
  777. }
  778. )
  779. })
  780. it('handles encoded hashtags', function (ctx) {
  781. const token = 'zxpxjrwdtsgd'
  782. const prefix = ctx.TokenAccessHandler.createTokenHashPrefix(token)
  783. ctx.TokenAccessHandler.checkTokenHashPrefix(
  784. token,
  785. `%23${prefix}`,
  786. 'readOnly',
  787. userId,
  788. { projectId }
  789. )
  790. expect(ctx.Metrics.inc).to.have.been.calledWith(
  791. 'link-sharing.hash-check',
  792. {
  793. path: 'readOnly',
  794. status: 'match',
  795. }
  796. )
  797. })
  798. it('sends "mismatch-v1-format" for suspected v1 URLs with 7 numbers in URL fragment', function (ctx) {
  799. const token = '4112142489ddsbkrdzhxrq'
  800. const prefix = '%2F1234567%2F'
  801. ctx.TokenAccessHandler.checkTokenHashPrefix(
  802. token,
  803. `#${prefix}`,
  804. 'readAndWrite',
  805. userId,
  806. { projectId }
  807. )
  808. expect(ctx.Metrics.inc).to.have.been.calledWith(
  809. 'link-sharing.hash-check',
  810. {
  811. path: 'readAndWrite',
  812. status: 'mismatch-v1-format',
  813. }
  814. )
  815. })
  816. it('sends "mismatch-v1-format" for suspected v1 URLs with 8 numbers in URL fragment', function (ctx) {
  817. const token = '4112142489ddsbkrdzhxrq'
  818. const prefix = '%2F12345678%2F'
  819. ctx.TokenAccessHandler.checkTokenHashPrefix(
  820. token,
  821. `#${prefix}`,
  822. 'readAndWrite',
  823. userId,
  824. { projectId }
  825. )
  826. expect(ctx.Metrics.inc).to.have.been.calledWith(
  827. 'link-sharing.hash-check',
  828. {
  829. path: 'readAndWrite',
  830. status: 'mismatch-v1-format',
  831. }
  832. )
  833. })
  834. })
  835. })