import { vi, expect } from 'vitest'
import path from 'node:path'
import sinon from 'sinon'
import mongodb from 'mongodb-legacy'
import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs'

const modulePath = path.join(
  import.meta.dirname,
  '../../../../app/src/Features/TokenAccess/TokenAccessHandler'
)

vi.mock('node:crypto', async () => {
  const originalModule = await vi.importActual('node:crypto')
  return {
    default: {
      ...originalModule,
      timingSafeEqual: vi.fn(originalModule.default.timingSafeEqual),
    },
  }
})

const { ObjectId } = mongodb

describe('TokenAccessHandler', function () {
  beforeEach(async function (ctx) {
    ctx.token = 'abcdefabcdef'
    ctx.projectId = new ObjectId()
    ctx.project = {
      _id: ctx.projectId,
      publicAccesLevel: 'tokenBased',
      owner_ref: new ObjectId(),
    }
    ctx.userId = new ObjectId()
    ctx.req = {}

    vi.doMock('mongodb-legacy', () => ({
      default: { ObjectId },
    }))

    vi.doMock('../../../../app/src/models/Project', () => ({
      Project: (ctx.Project = {}),
    }))

    vi.doMock('@overleaf/settings', () => ({
      default: (ctx.settings = { disableLinkSharing: false }),
    }))

    vi.doMock('../../../../app/src/Features/V1/V1Api', () => ({
      default: (ctx.V1Api = {
        promises: {
          request: sinon.stub(),
        },
      }),
    }))

    ctx.Crypto = (await vi.importMock('node:crypto')).default

    vi.doMock(
      '../../../../app/src/Features/Analytics/AnalyticsManager',
      () => ({
        default: (ctx.Analytics = {
          recordEventForUserInBackground: sinon.stub(),
        }),
      })
    )

    vi.doMock('../../../../app/src/infrastructure/Features', () => ({
      default: (ctx.Features = {}),
    }))

    ctx.TokenAccessHandler = (await import(modulePath)).default
  })

  describe('when link sharing is enabled', function () {
    beforeEach(function (ctx) {
      ctx.Features.hasFeature = sinon
        .stub()
        .withArgs('link-sharing')
        .returns(true)
    })
    describe('getTokenType', function () {
      it('should determine tokens correctly', function (ctx) {
        const specs = {
          abcdefabcdef: 'readOnly',
          aaaaaabbbbbb: 'readOnly',
          '54325aaaaaa': 'readAndWrite',
          '54325aaaaaabbbbbb': 'readAndWrite',
          '': null,
          abc123def: null,
        }
        for (const token of Object.keys(specs)) {
          expect(ctx.TokenAccessHandler.getTokenType(token)).to.equal(
            specs[token]
          )
        }
      })
    })

    describe('getProjectByReadOnlyToken', function () {
      beforeEach(function (ctx) {
        ctx.token = 'abcdefabcdef'
        ctx.Project.findOne = sinon.stub().returns({
          exec: sinon.stub().resolves(ctx.project),
        })
      })

      it('should get the project', async function (ctx) {
        const project =
          await ctx.TokenAccessHandler.promises.getProjectByReadOnlyToken(
            ctx.token
          )
        expect(project).to.exist
        expect(ctx.Project.findOne.callCount).to.equal(1)
      })
    })

    describe('getProjectByReadAndWriteToken', function () {
      beforeEach(function (ctx) {
        ctx.token = '1234abcdefabcdef'
        ctx.project.tokens = {
          readAndWrite: ctx.token,
          readAndWritePrefix: '1234',
        }
        ctx.Project.findOne = sinon.stub().returns({
          exec: sinon.stub().resolves(ctx.project),
        })
      })

      it('should get the project and do timing-safe comparison', async function (ctx) {
        const project =
          await ctx.TokenAccessHandler.promises.getProjectByReadAndWriteToken(
            ctx.token
          )
        expect(project).to.exist
        expect(ctx.Crypto.timingSafeEqual).toHaveBeenCalledTimes(1)
        expect(
          ctx.Crypto.timingSafeEqual.mock.calls[0][0].equals(
            Buffer.from(ctx.token)
          )
        ).toBeTruthy()
        expect(ctx.Project.findOne.callCount).to.equal(1)
      })
    })

    describe('addReadOnlyUserToProject', function () {
      beforeEach(function (ctx) {
        ctx.Project.updateOne = sinon.stub().returns({
          exec: sinon.stub().resolves(null),
        })
      })

      it('should call Project.updateOne', async function (ctx) {
        await ctx.TokenAccessHandler.promises.addReadOnlyUserToProject(
          ctx.userId,
          ctx.projectId,
          ctx.project.owner_ref
        )
        expect(ctx.Project.updateOne.callCount).to.equal(1)
        expect(
          ctx.Project.updateOne.calledWith({
            _id: ctx.projectId,
          })
        ).to.equal(true)
        expect(ctx.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys(
          'tokenAccessReadOnly_refs'
        )
        sinon.assert.calledWith(
          ctx.Analytics.recordEventForUserInBackground,
          ctx.userId,
          'project-joined',
          {
            mode: 'view',
            role: PrivilegeLevels.READ_ONLY,
            projectId: ctx.projectId.toString(),
            ownerId: ctx.project.owner_ref.toString(),
            source: 'link-sharing',
          }
        )
      })

      describe('when Project.updateOne produces an error', function () {
        beforeEach(function (ctx) {
          ctx.Project.updateOne = sinon
            .stub()
            .returns({ exec: sinon.stub().rejects(new Error('woops')) })
        })

        it('should be rejected', async function (ctx) {
          await expect(
            ctx.TokenAccessHandler.promises.addReadOnlyUserToProject(
              ctx.userId,
              ctx.projectId
            )
          ).to.be.rejected
        })
      })
    })

    describe('removeReadAndWriteUserFromProject', function () {
      beforeEach(function (ctx) {
        ctx.Project.updateOne = sinon
          .stub()
          .returns({ exec: sinon.stub().resolves(null) })
      })

      it('should call Project.updateOne', async function (ctx) {
        await ctx.TokenAccessHandler.promises.removeReadAndWriteUserFromProject(
          ctx.userId,
          ctx.projectId
        )

        expect(ctx.Project.updateOne.callCount).to.equal(1)
        expect(
          ctx.Project.updateOne.calledWith({
            _id: ctx.projectId,
          })
        ).to.equal(true)
        expect(ctx.Project.updateOne.lastCall.args[1].$pull).to.have.keys(
          'tokenAccessReadAndWrite_refs'
        )
      })
    })

    describe('moveReadAndWriteUserToReadOnly', function () {
      beforeEach(function (ctx) {
        ctx.Project.updateOne = sinon
          .stub()
          .returns({ exec: sinon.stub().resolves(null) })
      })

      it('should call Project.updateOne', async function (ctx) {
        await ctx.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly(
          ctx.userId,
          ctx.projectId
        )

        expect(ctx.Project.updateOne.callCount).to.equal(1)
        expect(
          ctx.Project.updateOne.calledWith({
            _id: ctx.projectId,
          })
        ).to.equal(true)
        expect(ctx.Project.updateOne.lastCall.args[1].$pull).to.have.keys(
          'tokenAccessReadAndWrite_refs'
        )
        expect(ctx.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys(
          'tokenAccessReadOnly_refs'
        )
      })
    })

    describe('grantSessionTokenAccess', function () {
      beforeEach(function (ctx) {
        ctx.req = { session: {}, headers: {} }
      })

      it('should add the token to the session', function (ctx) {
        ctx.TokenAccessHandler.promises.grantSessionTokenAccess(
          ctx.req,
          ctx.projectId,
          ctx.token
        )
        expect(
          ctx.req.session.anonTokenAccess[ctx.projectId.toString()]
        ).to.equal(ctx.token)
      })
    })

    describe('validateTokenForAnonymousAccess', function () {
      describe('when a read-only project is found', function () {
        beforeEach(function (ctx) {
          ctx.TokenAccessHandler.getTokenType = sinon.stub().returns('readOnly')
          ctx.TokenAccessHandler.promises.getProjectByToken = sinon
            .stub()
            .resolves(ctx.project)
        })

        it('should try to find projects with both kinds of token', async function (ctx) {
          await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
            ctx.projectId,
            ctx.token
          )

          expect(
            ctx.TokenAccessHandler.promises.getProjectByToken.callCount
          ).to.equal(1)
        })

        it('should allow read-only access', async function (ctx) {
          const { isValidReadAndWrite, isValidReadOnly } =
            await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
              ctx.projectId,
              ctx.token
            )

          expect(isValidReadAndWrite).to.equal(false)
          expect(isValidReadOnly).to.equal(true)
        })
      })

      describe('when a read-and-write project is found', function () {
        beforeEach(function (ctx) {
          ctx.TokenAccessHandler.promises.getTokenType = sinon
            .stub()
            .returns('readAndWrite')
          ctx.TokenAccessHandler.promises.getProjectByToken = sinon
            .stub()
            .resolves(ctx.project)
        })

        describe('when Anonymous token access is not enabled', function () {
          beforeEach(function (ctx) {
            ctx.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false
          })

          it('should try to find projects with both kinds of token', async function (ctx) {
            await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
              ctx.projectId,
              ctx.token
            )

            expect(
              ctx.TokenAccessHandler.promises.getProjectByToken.callCount
            ).to.equal(1)
          })

          it('should not allow read-and-write access', async function (ctx) {
            const { isValidReadAndWrite, isValidReadOnly } =
              await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
                ctx.projectId,
                ctx.token
              )

            expect(isValidReadAndWrite).to.equal(false)
            expect(isValidReadOnly).to.equal(false)
          })
        })

        describe('when anonymous token access is enabled', function () {
          beforeEach(function (ctx) {
            ctx.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true
          })

          it('should try to find projects with both kinds of token', async function (ctx) {
            await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
              ctx.projectId,
              ctx.token
            )

            expect(
              ctx.TokenAccessHandler.promises.getProjectByToken.callCount
            ).to.equal(1)
          })

          it('should allow read-and-write access', async function (ctx) {
            const { isValidReadAndWrite, isValidReadOnly } =
              await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
                ctx.projectId,
                ctx.token
              )

            expect(isValidReadAndWrite).to.equal(true)
            expect(isValidReadOnly).to.equal(false)
          })
        })
      })

      describe('when no project is found', function () {
        beforeEach(function (ctx) {
          ctx.TokenAccessHandler.promises.getProjectByToken = sinon
            .stub()
            .resolves(null)
        })

        it('should try to find projects with both kinds of token', async function (ctx) {
          await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
            ctx.projectId,
            ctx.token
          )

          expect(
            ctx.TokenAccessHandler.promises.getProjectByToken.callCount
          ).to.equal(1)
        })

        it('should not allow any access', async function (ctx) {
          const { isValidReadAndWrite, isValidReadOnly } =
            await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
              ctx.projectId,
              ctx.token
            )

          expect(isValidReadAndWrite).to.equal(false)
          expect(isValidReadOnly).to.equal(false)
        })
      })

      describe('when findProject produces an error', function () {
        beforeEach(function (ctx) {
          ctx.TokenAccessHandler.promises.getProjectByToken = sinon
            .stub()
            .rejects(new Error('woops'))
        })

        it('should try to find projects with both kinds of token', async function (ctx) {
          await expect(
            ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
              ctx.projectId,
              ctx.token
            )
          ).to.be.rejected

          expect(
            ctx.TokenAccessHandler.promises.getProjectByToken.callCount
          ).to.equal(1)
        })

        it('should produce an error and not allow access', async function (ctx) {
          await expect(
            ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
              ctx.projectId,
              ctx.token
            )
          ).to.be.rejected
        })
      })

      describe('when project is not set to token-based access', function () {
        beforeEach(function (ctx) {
          ctx.project.publicAccesLevel = 'private'
        })

        describe('for read-and-write project', function () {
          beforeEach(function (ctx) {
            ctx.TokenAccessHandler.getTokenType = sinon
              .stub()
              .returns('readAndWrite')
            ctx.TokenAccessHandler.promises.getProjectByToken = sinon
              .stub()
              .resolves(ctx.project)
          })

          it('should not allow any access', async function (ctx) {
            const { isValidReadAndWrite, isValidReadOnly } =
              await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
                ctx.projectId,
                ctx.token
              )

            expect(isValidReadAndWrite).to.equal(false)
            expect(isValidReadOnly).to.equal(false)
          })
        })

        describe('for read-only project', function () {
          beforeEach(function (ctx) {
            ctx.TokenAccessHandler.getTokenType = sinon
              .stub()
              .returns('readOnly')
            ctx.TokenAccessHandler.promises.getProjectByToken = sinon
              .stub()
              .resolves(ctx.project)
          })

          it('should not allow any access', async function (ctx) {
            const { isValidReadAndWrite, isValidReadOnly } =
              await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
                ctx.projectId,
                ctx.token
              )

            expect(isValidReadAndWrite).to.equal(false)
            expect(isValidReadOnly).to.equal(false)
          })
        })

        describe('with nothing', function () {
          beforeEach(function (ctx) {
            ctx.TokenAccessHandler.promises.getProjectByToken = sinon
              .stub()
              .resolves(null)
          })

          it('should not allow any access', async function (ctx) {
            const { isValidReadAndWrite, isValidReadOnly } =
              await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
                ctx.projectId,
                ctx.token
              )

            expect(isValidReadAndWrite).to.equal(false)
            expect(isValidReadOnly).to.equal(false)
          })
        })
      })
    })
  })

  describe('when link sharing is disabled', function () {
    beforeEach(function (ctx) {
      ctx.Features.hasFeature = sinon
        .stub()
        .withArgs('link-sharing')
        .returns(false)
    })

    describe('addReadOnlyUserToProject', function () {
      beforeEach(function (ctx) {
        ctx.Project.updateOne = sinon.stub().returns({
          exec: sinon.stub().resolves(null),
        })
      })

      it('should throw an error', async function (ctx) {
        await expect(
          ctx.TokenAccessHandler.promises.addReadOnlyUserToProject(
            ctx.userId,
            ctx.projectId,
            ctx.project.owner_ref
          )
        ).to.be.rejectedWith('link sharing is disabled')
        expect(ctx.Project.updateOne.callCount).to.equal(0)
      })
    })

    describe('grantSessionTokenAccess', function () {
      beforeEach(function (ctx) {
        ctx.req = { session: {}, headers: {} }
      })

      it('should throw an error', function (ctx) {
        expect(() => {
          ctx.TokenAccessHandler.promises.grantSessionTokenAccess(
            ctx.req,
            ctx.projectId,
            ctx.token
          )
        }).to.throw('link sharing is disabled')
        expect(ctx.req.session.anonTokenAccess).to.be.undefined
      })
    })

    describe('validateTokenForAnonymousAccess', function () {
      describe('when a read-only project is found', function () {
        beforeEach(function (ctx) {
          ctx.TokenAccessHandler.getTokenType = sinon.stub().returns('readOnly')
          ctx.TokenAccessHandler.promises.getProjectByToken = sinon
            .stub()
            .resolves(ctx.project)
        })

        it('should refuse access', async function (ctx) {
          const { isValidReadAndWrite, isValidReadOnly } =
            await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
              ctx.projectId,
              ctx.token
            )

          expect(isValidReadAndWrite).to.equal(false)
          expect(isValidReadOnly).to.equal(false)
        })
      })

      describe('when a read-and-write project is found', function () {
        beforeEach(function (ctx) {
          ctx.TokenAccessHandler.promises.getTokenType = sinon
            .stub()
            .returns('readAndWrite')
          ctx.TokenAccessHandler.promises.getProjectByToken = sinon
            .stub()
            .resolves(ctx.project)
        })

        describe('when Anonymous token access is not enabled', function () {
          beforeEach(function (ctx) {
            ctx.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false
          })

          it('should refuse access', async function (ctx) {
            const { isValidReadAndWrite, isValidReadOnly } =
              await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
                ctx.projectId,
                ctx.token
              )

            expect(isValidReadAndWrite).to.equal(false)
            expect(isValidReadOnly).to.equal(false)
          })
        })

        describe('when anonymous token access is enabled', function () {
          beforeEach(function (ctx) {
            ctx.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true
          })

          it('should not try to find any projects', async function (ctx) {
            await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
              ctx.projectId,
              ctx.token
            )

            expect(
              ctx.TokenAccessHandler.promises.getProjectByToken.callCount
            ).to.equal(0)
          })

          it('should refuse access', async function (ctx) {
            const { isValidReadAndWrite, isValidReadOnly } =
              await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
                ctx.projectId,
                ctx.token
              )

            expect(isValidReadAndWrite).to.equal(false)
            expect(isValidReadOnly).to.equal(false)
          })
        })
      })

      describe('when no project is found', function () {
        beforeEach(function (ctx) {
          ctx.TokenAccessHandler.promises.getProjectByToken = sinon
            .stub()
            .resolves(null)
        })

        it('should not try to find any projects ', async function (ctx) {
          await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
            ctx.projectId,
            ctx.token
          )

          expect(
            ctx.TokenAccessHandler.promises.getProjectByToken.callCount
          ).to.equal(0)
        })

        it('should not allow any access', async function (ctx) {
          const { isValidReadAndWrite, isValidReadOnly } =
            await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess(
              ctx.projectId,
              ctx.token
            )

          expect(isValidReadAndWrite).to.equal(false)
          expect(isValidReadOnly).to.equal(false)
        })
      })
    })
  })

  describe('getDocPublishedInfo', function () {
    describe('when v1 api not set', function () {
      beforeEach(function (ctx) {
        ctx.settings.apis = { v1: undefined }
      })

      it('should not check access and return default info', async function (ctx) {
        const info =
          await ctx.TokenAccessHandler.promises.getV1DocPublishedInfo(ctx.token)

        expect(ctx.V1Api.promises.request.called).to.equal(false)
        expect(info).to.deep.equal({
          allow: true,
        })
      })
    })

    describe('when v1 api is set', function () {
      beforeEach(function (ctx) {
        ctx.settings.apis = { v1: { url: 'v1Url' } }
      })

      describe('on V1Api.request success', function () {
        beforeEach(function (ctx) {
          ctx.V1Api.promises.request = sinon
            .stub()
            .resolves({ body: 'mock-data' })
        })

        it('should return response body', async function (ctx) {
          const info =
            await ctx.TokenAccessHandler.promises.getV1DocPublishedInfo(
              ctx.token
            )

          expect(
            ctx.V1Api.promises.request.calledWith({
              url: `/api/v1/overleaf/docs/${ctx.token}/is_published`,
            })
          ).to.equal(true)
          expect(info).to.equal('mock-data')
        })
      })

      describe('on V1Api.request error', function () {
        beforeEach(function (ctx) {
          ctx.V1Api.promises.request = sinon.stub().rejects('error')
        })

        it('should be rejected', async function (ctx) {
          await expect(
            ctx.TokenAccessHandler.promises.getV1DocPublishedInfo(ctx.token)
          ).to.be.rejected
        })
      })
    })
  })

  describe('getV1DocInfo', function () {
    describe('when v1 api not set', function () {
      it('should not check access and return default info', async function (ctx) {
        const info = await ctx.TokenAccessHandler.promises.getV1DocInfo(
          ctx.token,
          ctx.v2UserId
        )

        expect(ctx.V1Api.promises.request.called).to.equal(false)
        expect(info).to.deep.equal({
          exists: true,
          exported: false,
        })
      })
    })

    describe('when v1 api is set', function () {
      beforeEach(function (ctx) {
        ctx.settings.apis = { v1: 'v1' }
      })

      describe('on V1Api.request success', function () {
        beforeEach(function (ctx) {
          ctx.V1Api.promises.request = sinon
            .stub()
            .resolves({ body: 'mock-data' })
        })

        it('should return response body', async function (ctx) {
          const info = await ctx.TokenAccessHandler.promises.getV1DocInfo(
            ctx.token,
            ctx.v2UserId
          )

          expect(
            ctx.V1Api.promises.request.calledWith({
              url: `/api/v1/overleaf/docs/${ctx.token}/info`,
            })
          ).to.equal(true)
          expect(info).to.equal('mock-data')
        })
      })

      describe('on V1Api.request error', function () {
        beforeEach(function (ctx) {
          ctx.V1Api.promises.request = sinon.stub().rejects('error')
        })

        it('should be rejected', async function (ctx) {
          await expect(
            ctx.TokenAccessHandler.promises.getV1DocInfo(
              ctx.token,
              ctx.v2UserId
            )
          ).to.be.rejected
        })
      })
    })
  })

  describe('createTokenHashPrefix', function () {
    it('creates a prefix of the hash', function (ctx) {
      const prefix =
        ctx.TokenAccessHandler.createTokenHashPrefix('zxpxjrwdtsgd')
      expect(prefix.length).to.equal(6)
    })
  })

  describe('normalizeTokenHashPrefix', function () {
    const cases = {
      // hex string
      ab2345: 'ab2345',
      '01234f': '01234f',
      '012345': '012345',
      // remove (encoded) hash
      '#012345': '012345',
      '%23012345': '012345',
      // remove trailing special characters
      '012345.': '012345',
      '012345/': '012345',
      // v1 doc
      '%2F1234567%2F': '%2F1234567%2F',
    }
    for (const [input, output] of Object.entries(cases)) {
      it(`should handle ${JSON.stringify(input)}`, function (ctx) {
        expect(ctx.TokenAccessHandler.normalizeTokenHashPrefix(input)).to.equal(
          output
        )
      })
    }
  })

  describe('checkTokenHashPrefix', function () {
    const userId = 'abc123'
    const projectId = 'def456'
    it('sends "match" to metrics when prefix matches the prefix of the hash of the token', function (ctx) {
      const token = 'zxpxjrwdtsgd'
      const prefix = ctx.TokenAccessHandler.createTokenHashPrefix(token)

      ctx.TokenAccessHandler.checkTokenHashPrefix(
        token,
        `#${prefix}`,
        'readOnly',
        userId,
        { projectId }
      )

      expect(ctx.Metrics.inc).to.have.been.calledWith(
        'link-sharing.hash-check',
        {
          path: 'readOnly',
          status: 'match',
        }
      )
    })
    it('sends "mismatch" to metrics when prefix does not match the prefix of the hash of the token', function (ctx) {
      const token = 'zxpxjrwdtsgd'
      const prefix = ctx.TokenAccessHandler.createTokenHashPrefix(token)
      ctx.TokenAccessHandler.checkTokenHashPrefix(
        'anothertoken',
        `#${prefix}`,
        'readOnly',
        userId,
        { projectId }
      )

      expect(ctx.Metrics.inc).to.have.been.calledWith(
        'link-sharing.hash-check',
        {
          path: 'readOnly',
          status: 'mismatch',
        }
      )
      expect(ctx.logger.info).toHaveBeenCalledWith(
        {
          tokenHashPrefix: prefix,
          hashPrefixStatus: 'mismatch',
          userId,
          projectId,
          type: 'readOnly',
        },
        'mismatched token hash prefix'
      )
    })
    it('sends "missing" to metrics when prefix is undefined', function (ctx) {
      ctx.TokenAccessHandler.checkTokenHashPrefix(
        'anothertoken',
        undefined,
        'readOnly',
        userId,
        { projectId }
      )

      expect(ctx.Metrics.inc).to.have.been.calledWith(
        'link-sharing.hash-check',
        {
          path: 'readOnly',
          status: 'missing',
        }
      )
    })
    it('sends "missing" to metrics when URL hash is sent as "#" only', function (ctx) {
      ctx.TokenAccessHandler.checkTokenHashPrefix(
        'anothertoken',
        '#',
        'readOnly',
        userId,
        { projectId }
      )

      expect(ctx.Metrics.inc).to.have.been.calledWith(
        'link-sharing.hash-check',
        {
          path: 'readOnly',
          status: 'missing',
        }
      )
    })
    it('handles encoded hashtags', function (ctx) {
      const token = 'zxpxjrwdtsgd'
      const prefix = ctx.TokenAccessHandler.createTokenHashPrefix(token)

      ctx.TokenAccessHandler.checkTokenHashPrefix(
        token,
        `%23${prefix}`,
        'readOnly',
        userId,
        { projectId }
      )

      expect(ctx.Metrics.inc).to.have.been.calledWith(
        'link-sharing.hash-check',
        {
          path: 'readOnly',
          status: 'match',
        }
      )
    })

    it('sends "mismatch-v1-format" for suspected v1 URLs with 7 numbers in URL fragment', function (ctx) {
      const token = '4112142489ddsbkrdzhxrq'
      const prefix = '%2F1234567%2F'
      ctx.TokenAccessHandler.checkTokenHashPrefix(
        token,
        `#${prefix}`,
        'readAndWrite',
        userId,
        { projectId }
      )
      expect(ctx.Metrics.inc).to.have.been.calledWith(
        'link-sharing.hash-check',
        {
          path: 'readAndWrite',
          status: 'mismatch-v1-format',
        }
      )
    })
    it('sends "mismatch-v1-format" for suspected v1 URLs with 8 numbers in URL fragment', function (ctx) {
      const token = '4112142489ddsbkrdzhxrq'
      const prefix = '%2F12345678%2F'
      ctx.TokenAccessHandler.checkTokenHashPrefix(
        token,
        `#${prefix}`,
        'readAndWrite',
        userId,
        { projectId }
      )
      expect(ctx.Metrics.inc).to.have.been.calledWith(
        'link-sharing.hash-check',
        {
          path: 'readAndWrite',
          status: 'mismatch-v1-format',
        }
      )
    })
  })
})
