TokenAccessHandlerTests.js 22 KB

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