[
  {
    "path": ".dev.vars.example",
    "content": "# This file is used to set secrets for running a local dev server via npm run dev as well as tests\n# Use a valid fake PKCS8 key for apple oauth\nJWT_SECRET = \"iamarandomjwtsecret\"\nDATABASE_PASSWORD = \"database password\"\nAWS_ACCESS_KEY_ID = \"realorfake\"\nAWS_SECRET_ACCESS_KEY = \"realorfake\"\nSENTRY_DSN = \"realorempty\"\nOAUTH_GITHUB_CLIENT_SECRET = \"realorfake\"\nOAUTH_DISCORD_CLIENT_SECRET = \"realorfake\"\nOAUTH_SPOTIFY_CLIENT_SECRET = \"realorfake\"\nOAUTH_GOOGLE_CLIENT_SECRET = \"realorfake\"\nOAUTH_FACEBOOK_CLIENT_SECRET = \"realorfake\"\nOAUTH_APPLE_PRIVATE_KEY = \"-----BEGIN PRIVATE KEY-----MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCFUxrQFuI9oTnQb8SeYBGJkiVeB85Xare9anUaKhRTyuP83n/UxBTFpEc/DTFwy3o/7OKvqLhhBhbrinsfjV7ZXMJJzPEDeMcgdFfwaooGapEJMEb9QkjfXAXidffyDgxeFXvs0nd1IWWTVKnou6859FyNGabhRN8eA+syOgZk4RfAPwiXZl5q+jDdHKrH/fVKhgkihlGbZFHevEHvHLE1ZxLTcMWxQjOUIyS86o+N6hHWTNJsSmo7apYDkEjQQNosegq50xTmml6NcjwtV2v9tVzaCeGAOl9IyJfhHRBYPt16v/DqpbWCd4S9REut79GJR1lW5ZZm0stpDD7myM3BAgMBAAECggEAAR7TaxvCH3f3IyoJSjZu90u/3iQtJv1p2WDnZoajgJfEJjgddWWHcijBA4XiHDtNhfOA7S57DC+vqh+SDNAUk7mMlW+wN9IARGTN52KR0d975AqgkhjIQX5Fu2M35/QXxQOjtLgJEnYrIxuTSPYo0REdZP8p8JsyT8+DHrsvmhHqFQHszNEjnZ33/JAFelOUJgbeisHRv99Voiv2qQi4al//iZ00NKrRvkRA417Mim1cwhkSnbzUXdhLAH4MInGzizmQVpHlzSYVV1vzXqYXH0K8arMY60fCqf9zxKwYCbfEJjnAREof9vs19V9wnHtbI9rcODEfRz5I/61oLrGl0QKBgQC5wuQiUjFX3GKUXuVOvvMZIb8Zk5T8QPhamZDHHlx3v3vZ8srlQ8tSSqaMfgSYuSzYDi4LAOI1nZtBHp33D4XZZQKLql3cA9i0xrJtHim6ep2w+7if0mr57v+sgG16eS3AHiTknYilzO56MSzaOewlgHevcw4IhoNlFEyDAECQKQKBgQC3vIJh6phpj5lOY3xAGb/rVUZ/PtNZco2ppGm6Q7u3RDkey55oNxGXOXOw8Jrv3qlieNSoId/ZMrX0Mfe41K2HYQo78qVGWA+kOagc3tsP0uAK9EYLs034HTAaJof6XDHUgUGwmLJ/zDrc9Lk1S5cUZ6Js26o/gBASVQZzsLEj2QKBgQCm6BLdN6a4P/+fOoiksXNx4F15WJ5j7Oh5V0O7dW819SoOEVX2m2xje0mcMFpm8vL1CgCayGd4Ly1hXGYop5znURfxb9k3p4keHO4Slyh9MlDfxb0EdSbDfNfjId28Tocp+KvDcjxmZPTde7PGPIcOxxhC34j7ZglHV+7LQf3AyQKBgBPsCq8XQsNfYJ4RR22j3R1lN6mgZEY0l4unWhdqNLZgXVkrdteR8QRWpGaxD/umRvN4aoZ4dc8VIomByXxvAwnEydlKLAV+kuOZpNLMjzAeC1Dkv5uRK4kVkRukxeWtjXGfOkItrF0TBebjWhmfQphhzEjFYKZV+mgic/qjU/GxAoGBALQlMAXKKPrt/nrAcTTjQhPNhTKKRhkcICdTVZhbKkHRbwBiMVsPUvDualQpkflzYEdf+mBxz6g9Gr4S8scY/1CljlH+SHK1QA7seGXlQ3+saY2PLCDtV8cuZO4ggV0tbTmR8RQCMNO0HUjfSD4vkwmnu8nYbI8TalKFG3t8NrV2-----END PRIVATE KEY-----\"\n"
  },
  {
    "path": ".env.example",
    "content": "# Please note that this file is only used for running migrations for a development database\n# The rest of the variables for the development environment are setup in the wrangler.toml file\nDATABASE_NAME='name'\nDATABASE_USERNAME='username'\nDATABASE_HOST='host'\nDATABASE_PASSWORD='password'\n"
  },
  {
    "path": ".env.test.example",
    "content": "# Please note that aws credentials and oauth credentials don't have to work only planetscale\n# The apple oauth private key must be in the pkcs8 format\n# credentials are required to run the tests.\nENV = 'test'\nJWT_SECRET='iamasecret'\nJWT_ACCESS_EXPIRATION_MINUTES=30\nJWT_REFRESH_EXPIRATION_DAYS=30\nJWT_RESET_PASSWORD_EXPIRATION_MINUTES=15\nJWT_VERIFY_EMAIL_EXPIRATION_MINUTES=15\nDATABASE_NAME='name'\nDATABASE_USERNAME='username'\nDATABASE_HOST='host'\nDATABASE_PASSWORD='password'\nAWS_ACCESS_KEY_ID='test'\nAWS_SECRET_ACCESS_KEY='test'\nAWS_REGION='eu-west-1'\nEMAIL_SENDER='noreply@dictionaryapi.io'\nSENTRY_DSN=''\nOAUTH_WEB_REDIRECT_URL='https://frontend.com/login'\nOAUTH_IOS_REDIRECT_URL='app://login'\nOAUTH_ANDROID_REDIRECT_URL='app://login'\nOAUTH_GITHUB_CLIENT_ID='myclientid'\nOAUTH_GITHUB_CLIENT_SECRET='myclientsecret'\nOAUTH_DISCORD_CLIENT_ID='myclientid'\nOAUTH_DISCORD_CLIENT_SECRET='myclientsecret'\nOAUTH_SPOTIFY_CLIENT_ID='myclientid'\nOAUTH_SPOTIFY_CLIENT_SECRET='myclientsecret'\nOAUTH_GOOGLE_CLIENT_ID='myclientid'\nOAUTH_GOOGLE_CLIENT_SECRET='myclientsecret'\nOAUTH_GOOGLE_REDIRECT_URL='https://frontend.com/login'\nOAUTH_FACEBOOK_CLIENT_ID='myclientid'\nOAUTH_FACEBOOK_CLIENT_SECRET='myclientsecret'\nOAUTH_APPLE_CLIENT_ID='myclientid'\nOAUTH_APPLE_PRIVATE_KEY='-----BEGIN PRIVATE KEY-----\\n\\n-----END PRIVATE KEY-----'\nOAUTH_APPLE_KEY_ID='mykeyid'\nOAUTH_APPLE_TEAM_ID='myteamid'\nOAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES=30\nOAUTH_APPLE_REDIRECT_URL='https://frontend.com/login'\n"
  },
  {
    "path": ".eslintignore",
    "content": "node_modules\ndist\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\n/dist\n**/*.rs.bk\npkg/\nwasm-pack.log\nworker/\nnode_modules/\n.vscode\n.env*\n!*.example\n.mf\nwrangler.toml\ncoverage\n.vscode\npnpm-lock.yaml\n.dev.vars\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "npm run lint\n"
  },
  {
    "path": ".husky/pre-push",
    "content": "npm run tests:coverage\n"
  },
  {
    "path": ".prettierignore",
    "content": "node_modules\ndist\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"singleQuote\": true,\n  \"semi\": false,\n  \"trailingComma\": \"none\",\n  \"tabWidth\": 2,\n  \"printWidth\": 100,\n  \"bracketSpacing\": true\n}\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2022 Ben Louis Armstrong\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# RESTful API Cloudflare Workers Boilerplate\nA boilerplate/starter project for quickly building RESTful APIs using\n[Cloudflare Workers](https://workers.cloudflare.com/), [Hono](https://honojs.dev/), and\n[PlanetScale](https://planetscale.com/). Inspired by\n[node-express-boilerplate](https://github.com/hagopj13/node-express-boilerplate) by hagopj13.\n\n## Quick Start\n\nTo create a project, simply run:\n\n```bash\nnpx create-cf-planetscale-app <project-name>\n```\n\nOr\n\n```bash\nnpm init cf-planetscale-app <project-name>\n```\n\n## Table of Contents\n\n- [RESTful API Cloudflare Workers Boilerplate](#restful-api-cloudflare-workers-boilerplate)\n  - [Quick Start](#quick-start)\n  - [Table of Contents](#table-of-contents)\n  - [Features](#features)\n  - [Commands](#commands)\n  - [Error Handling](#error-handling)\n  - [Validation](#validation)\n  - [Authentication](#authentication)\n  - [Emails](#emails)\n  - [Authorisation](#authorisation)\n  - [Rate Limiting](#rate-limiting)\n  - [Contributing](#contributing)\n  - [Inspirations](#inspirations)\n  - [License](#license)\n\n## Features\n\n- **SQL database**: [PlanetScale](https://planetscale.com/) using\n[Kysely](https://github.com/koskimas/kysely) as a type-safe SQl query builder\n- **Authentication and authorization**: using JWT\n- **Validation**: request data validation using [Zod](https://github.com/colinhacks/zod)\n- **Logging**: using [Sentry](https://sentry.io/)\n- **Testing**: unit and integration tests using [Vitest](https://vitest.dev/)\n- **Error handling**: centralised error handling mechanism provided by [Hono](https://honojs.dev/)\n- **Git hooks**: with [Husky](https://github.com/typicode/husky)\n- **Linting**: with [ESLint](https://eslint.org) and [Prettier](https://prettier.io)\n- **Emails**: with [Amazon SES](https://aws.amazon.com/ses/)\n- **Oauth**: Support for Discord, Github, Spotify, Google, Apple and Facebook. Support coming for\n  Instagram and Twitter\n- **Rate Limiting**: using Cloudflare durable objects you can rate limit endpoints usin the sliding\n  window algorithm\n\n## Commands\n\nRunning locally:\n\n```bash\nnpm run dev\n```\n\nTesting:\n\n```bash\n# run all tests\nnpm run tests\n\n# run test coverage\nnpm run tests:coverage\n```\n\nLinting:\n\n```bash\n# run ESLint\nnpm run lint\n\n# fix ESLint errors\nnpm run lint:fix\n\n# run prettier\nnpm run prettier\n\n# fix prettier errors\nnpm run prettier:fix\n```\n\nMigrations:\n\nTo deploy to production you must first deploy to a test/dev branch on Planetscale and then create\na deploy request and merge the schema into production.\n\n```bash\n# run all migrations for testing\nnpm run migrate:test:latest\n\n# remove all migrations for testing\nnpm run migrate:test:none\n\n# revert last migration for testing\nnpm run migrate:test:down\n```\n\nDeploy to Cloudflare:\n\n```bash\nnpm run deploy\nnpm run deploy\n```\n\n## Error Handling\n\nThe app has a centralized error handling mechanism provided by [Hono](https://honojs.dev/).\n\n```javascript\napp.onError(errorHandler)\n```\n\nAll errors will be caught by the errorHandler which converts the error to an ApiError and formats\nit in a JSON response. Any errors that aren't intentionally thrown, e.g. 500 errors, are logged to\nSentry.\n\nThe error handling middleware sends an error response, which has the following format:\n\n```json\n{\n  \"code\": 404,\n  \"message\": \"Not found\"\n}\n```\n\nWhen running in development mode, the error response also contains the error stack.\n\n## Validation\n\nRequest data is validated using [Zod](https://github.com/colinhacks/zod).\n\nThe validation schemas are defined in the `src/validations` directory and are used in the\ncontrollers by getting either the query or body and then calling the parse on the relevant\nvalidation function:\n\n\n```javascript\nconst getUsers: Handler<{ Bindings: Bindings }> = async (c) => {\n  const config = getConfig(c.env)\n  const queryParse = c.req.query()\n  const query = userValidation.getUsers.parse(queryParse)\n  const filter = { email: query.email }\n  const options = { sortBy: query.sort_by, limit: query.limit, page: query.page }\n  const result = await userService.queryUsers(filter, options, config.database)\n  return c.json(result, httpStatus.OK)\n}\n```\n\n## Authentication\n\nTo require authentication for certain routes, you can use the `auth` middleware.\n\n```javascript\nimport { Hono } from 'hono'\nimport * as userController from '../controllers/user.controller'\nimport { auth } from '../middlewares/auth'\n\nconst route = new Hono<{ Bindings: Bindings }>()\n\nroute.post('/', auth(), userController.createUser)\n\nexport { route }\n\n```\n\nThese routes require a valid JWT access token in the Authorization request header using the Bearer\nschema. If the request does not contain a valid access token, an Unauthorized (401) error is thrown.\n\n## Emails\n\nSupport for Email sending using [Amazon SES](https://aws.amazon.com/ses/). Just call the `sendEmail`\nfunction in `src/services/email.service.ts`:\n\n```javascript\nconst sendResetPasswordEmail = async (email: string, emailData: EmailData, config: Config) => {\n  const message = {\n    Subject: {\n      Data: 'Reset your password',\n      Charset: 'UTF-8'\n    },\n    Body: {\n      Text: {\n        Charset: 'UTF-8',\n        Data: `\n          Hello ${emailData.name}\n          Please reset your password by clicking the following link:\n          ${emailData.token}\n        `\n      }\n    }\n  }\n  await sendEmail(email, config.email.sender, message, config.aws)\n}\n```\n\n## Authorisation\n\nThe `auth` middleware can also be used to require certain rights/permissions to access a route.\n\n```javascript\nimport { Hono } from 'hono'\nimport * as userController from '../controllers/user.controller'\nimport { auth } from '../middlewares/auth'\n\nconst route = new Hono<{ Bindings: Bindings }>()\n\nroute.post('/', auth('manageUsers'), userController.createUser)\n\nexport { route }\n```\n\nIn the example above, an authenticated user can access this route only if that user has the\n`manageUsers` permission.\n\nThe permissions are role-based. You can view the permissions/rights of each role in the\n`src/config/roles.ts` file.\n\nIf the user making the request does not have the required permissions to access this route, a\nForbidden (403) error is thrown.\n\n## Rate Limiting\n\nTo apply rate limits for certain routes, you can use the `rateLimit` middleware.\n\n```javascript\nimport { Hono } from 'hono'\nimport { Environment } from '../../bindings'\nimport { auth } from '../middlewares/auth'\nimport { rateLimit } from '../middlewares/rateLimiter'\n\nexport const route = new Hono<Environment>()\n\nconst twoMinutes = 120\nconst oneRequest = 1\n\nroute.post(\n  '/send-verification-email',\n  auth(),\n  rateLimit(twoMinutes, oneRequest),\n  authController.sendVerificationEmail\n)\n```\n\nThis uses Cloudflare durable objects to apply rate limits using the sliding window algorithm. You\ncan specify the interval size in seconds and how many requests are allowed per interval.\n\nIf the rate limit is hit a `429` will be returned to the client.\n\nThese headers are returned with each endpoint that has rate limiting applied:\n\n* `X-RateLimit-Limit` - How many requests are allowed per window\n* `X-RateLimit-Reset` - How many seconds until the current window resets\n* `X-RateLimit-Policy` - Details about the rate limit policy in this format `${limit};w=${interval};comment=\"Sliding window\"`\n* `X-RateLimit-Remaining` - How many requests you can send until you will be rate limited. Please\nnote this doesn't just reset to the limit when the reset period hits. Use it as indicator of your\ncurrent throughput e.g. if you have 12 requests allowed every 1 second and remaining is 0\nyou are at 100% throughput, but if it is 6 you are 50% throughput. This value constantly changes\nas the window progresses either increasing or decreasing based on your throughput\n\nThe rate limit will be based on IP unless the user is authenticated then it will be based on the\nuser ID.\n\n## Contributing\n\nContributions are more than welcome!\n\n## Inspirations\n\n- [hagopj13/node-express-boilerplate](https://github.com/hagopj13/node-express-boilerplate)\n\n## License\n\n[MIT](LICENSE)\n"
  },
  {
    "path": "TODO.md",
    "content": "### Todo\n\n- [ ] Flesh out README\n- [ ] Oauth\n  - [ ] Add support for Twitter\n- [ ] API docs\n- [ ] Fix all types\n- [ ] CI/CD using Github Actions\n- [ ] MFA\n- [ ] 100% test coverage\n- [ ] Unit tests\n\n### In Progress\n"
  },
  {
    "path": "bin/createApp.js",
    "content": "#!/usr/bin/env node\n\n/* eslint no-console: \"off\" */\nimport { exec as child_exec } from 'child_process'\nimport fs from 'fs'\nimport path from 'path'\nimport util from 'util'\n\n// Utility functions\nconst exec = util.promisify(child_exec)\n\nconst runCmd = async (command) => {\n  try {\n    const { stdout, stderr } = await exec(command)\n    console.log(stdout)\n    console.log(stderr)\n  } catch(err) {\n    console.log(err)\n  }\n}\n\n// Validate arguments\nif (process.argv.length < 3) {\n  console.log('Please specify the target project directory.')\n  console.log('For example:')\n  console.log('    npx create-cf-planetscale-app my-app')\n  console.log('    OR')\n  console.log('    npm init create-cf-planetscale-app my-app')\n  process.exit(1)\n}\n\n// Define constants\nconst ownPath = process.cwd()\nconst folderName = process.argv[2]\nconst appPath = path.join(ownPath, folderName)\nconst repo = 'https://github.com/OultimoCoder/cloudflare-planetscale-hono-boilerplate'\n\n// Check if directory already exists\ntry {\n  fs.mkdirSync(appPath)\n} catch (err) {\n  if (err.code === 'EEXIST') {\n    console.log('Directory already exists. Please choose another name for the project.')\n  } else {\n    console.log(err)\n  }\n  process.exit(1)\n}\n\nconst setup = async () => {\n  try {\n    // Clone repo\n    console.log(`Downloading files from repo ${repo}`)\n    await runCmd(`git clone --depth 1 ${repo} ${folderName}`)\n    console.log('Cloned successfully.')\n    console.log('')\n\n    // Change directory\n    process.chdir(appPath)\n\n    // Install dependencies\n    console.log('Installing dependencies...')\n    await runCmd('npm install')\n    console.log('Dependencies installed successfully.')\n    console.log()\n\n    // Copy wrangler.toml\n    fs.copyFileSync(\n      path.join(appPath, 'wrangler.toml.example'),\n      path.join(appPath, 'wrangler.toml')\n    )\n    console.log('wrangler.toml copied.')\n\n    // Delete .git folder\n    await runCmd('npx rimraf ./.git')\n\n    // Remove extra files\n    fs.unlinkSync(path.join(appPath, 'TODO.md'))\n    fs.unlinkSync(path.join(appPath, 'bin', 'createApp.js'))\n    fs.rmdirSync(path.join(appPath, 'bin'))\n\n    console.log('Installation is now complete!')\n    console.log()\n    console.log('Enjoy your production-ready Cloudflare Workers project!')\n    console.log('Check README.md for more info.')\n  } catch (error) {\n    console.log(error)\n  }\n}\n\nsetup()\n"
  },
  {
    "path": "bindings.d.ts",
    "content": "import type { JwtPayload } from '@tsndr/cloudflare-worker-jwt'\nimport type { Toucan } from 'toucan-js'\nimport type { RateLimiter } from './src/durable-objects/rate-limiter.do'\n\ntype Environment = {\n  Bindings: {\n    ENV: string\n    JWT_SECRET: string\n    JWT_ACCESS_EXPIRATION_MINUTES: number\n    JWT_REFRESH_EXPIRATION_DAYS: number\n    JWT_RESET_PASSWORD_EXPIRATION_MINUTES: number\n    JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: number\n    DATABASE_NAME: string\n    DATABASE_USERNAME: string\n    DATABASE_PASSWORD: string\n    DATABASE_HOST: string\n    RATE_LIMITER: DurableObjectNamespace<RateLimiter>\n    SENTRY_DSN: string\n    AWS_ACCESS_KEY_ID: string\n    AWS_SECRET_ACCESS_KEY: string\n    AWS_REGION: string\n    EMAIL_SENDER: string\n    OAUTH_WEB_REDIRECT_URL: string\n    OAUTH_ANDROID_REDIRECT_URL: string\n    OAUTH_IOS_REDIRECT_URL: string\n    OAUTH_GITHUB_CLIENT_ID: string\n    OAUTH_GITHUB_CLIENT_SECRET: string\n    OAUTH_GOOGLE_CLIENT_ID: string\n    OAUTH_GOOGLE_CLIENT_SECRET: string\n    OAUTH_DISCORD_CLIENT_ID: string\n    OAUTH_DISCORD_CLIENT_SECRET: string\n    OAUTH_SPOTIFY_CLIENT_ID: string\n    OAUTH_SPOTIFY_CLIENT_SECRET: string\n    OAUTH_FACEBOOK_CLIENT_ID: string\n    OAUTH_FACEBOOK_CLIENT_SECRET: string\n    OAUTH_APPLE_CLIENT_ID: string\n    OAUTH_APPLE_KEY_ID: string\n    OAUTH_APPLE_TEAM_ID: string\n    OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES: number\n    OAUTH_APPLE_PRIVATE_KEY: string\n  }\n  Variables: {\n    payload: JwtPayload\n    sentry: Toucan\n  }\n}\n"
  },
  {
    "path": "build.js",
    "content": "import { build } from 'esbuild'\n\ntry {\n  await build({\n      entryPoints: ['./src/index.ts'],\n      bundle: true,\n      outdir: './dist/',\n      sourcemap: true,\n      minify: true,\n      conditions: ['worker', 'browser'],\n      outExtension: { '.js': '.mjs' },\n      format: 'esm',\n      target: 'esnext'\n    })\n} catch {\n  process.exitCode = 1\n}\n\n"
  },
  {
    "path": "eslint.config.js",
    "content": "import eslint from '@eslint/js'\nimport importx from 'eslint-plugin-import-x'\nimport eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'\nimport vitest from 'eslint-plugin-vitest'\nimport globals from 'globals'\nimport tseslint from 'typescript-eslint'\n\nconst defaultFiles = [\n  'src/**',\n  'tests/**',\n  'bindings.d.ts',\n  'scripts/**',\n  'migrations/**'\n]\n\nconst config = {\n  languageOptions: {\n    sourceType: 'module',\n    ecmaVersion: 2021,\n    globals: {\n      ...globals.node,\n      ...globals.browser,\n      ...globals.serviceworker,\n      fetch: 'readonly',\n      Response: 'readonly',\n      Request: 'readonly',\n      addEventListener: 'readonly',\n      ENV: 'readonly'\n    },\n  },\n  plugins: { 'import-x': importx },\n  rules: {\n    quotes: ['error', 'single'],\n    'no-console': 'error',\n    'sort-imports': 'off',\n    'import-x/order': [\n      'error',\n      {\n        alphabetize: { order: 'asc' },\n      }\n    ],\n    'node/no-missing-import': 'off',\n    'node/no-missing-require': 'off',\n    'node/no-deprecated-api': 'off',\n    'node/no-unpublished-import': 'off',\n    'node/no-unpublished-require': 'off',\n    'node/no-unsupported-features/es-syntax': 'off',\n    semi: ['error', 'never'],\n    'no-debugger': ['error'],\n    'no-empty': ['warn', { allowEmptyCatch: true }],\n    'no-process-exit': 'off',\n    'no-useless-escape': 'off',\n    'max-len': ['error', { code: 100 }],\n    '@typescript-eslint/no-unused-vars': [\n      'error',\n      {\n        argsIgnorePattern: '^_',\n        varsIgnorePattern: '^_',\n        caughtErrorsIgnorePattern: '^_',\n      },\n    ],\n  },\n  files: defaultFiles\n}\n\nexport const testConfig = {\n  ...vitest.configs.recommended,\n  plugins: { vitest: vitest },\n  files: ['tests/**']\n}\n\nexport default tseslint.config(\n  {\n    ignores: ['dist/', 'coverage/', 'node_modules/'],\n  },\n  {\n    files: defaultFiles,\n    ...eslint.configs.recommended\n  },\n  ...tseslint.configs.recommended,\n  config,\n  testConfig,\n  {\n    files: defaultFiles,\n    ...eslintPluginPrettierRecommended\n  }\n)\n\n"
  },
  {
    "path": "jest.config.js",
    "content": "export default {\n  preset: 'ts-jest/presets/default-esm',\n  extensionsToTreatAsEsm: ['.ts'],\n  clearMocks: true,\n  globals: {\n    'ts-jest': {\n      tsconfig: 'tests/tsconfig.json',\n      useESM: true,\n      isolatedModules: true,\n    },\n  },\n  testEnvironment: 'miniflare',\n  testEnvironmentOptions: {\n    scriptPath: 'dist/index.mjs',\n    modules: true\n  },\n  transformIgnorePatterns: [\n    'node_modules/(?!(@planetscale|kysely-planetscale|@aws-sdk|worker-auth-providers|uuid))'\n  ],\n  moduleNameMapper: {'^uuid$': 'uuid'},\n  collectCoverageFrom: ['src/**/*.{ts,js}'],\n  coveragePathIgnorePatterns: [\n    'src/durable-objects'  // Jest doesn't accurately report coverage for Durable Objects\n  ],\n  testTimeout: 20000\n}\n"
  },
  {
    "path": "migrations/01_initial.ts",
    "content": "import { Kysely, sql } from 'kysely'\nimport { Database } from '../src/config/database'\n\nexport async function up(db: Kysely<Database>) {\n  await db.schema\n    .createTable('user')\n    .addColumn('id', 'varchar(21)', (col) => col.primaryKey())\n    .addColumn('name', 'varchar(255)')\n    .addColumn('password', 'varchar(255)')\n    .addColumn('email', 'varchar(255)', (col) => col.notNull().unique())\n    .addColumn('is_email_verified', 'boolean', (col) => col.defaultTo(false))\n    .addColumn('role', 'varchar(255)', (col) => col.defaultTo('user'))\n    .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`))\n    .addColumn('updated_at', 'timestamp', (col) => {\n      return col.defaultTo(sql`NOW()`).modifyEnd(sql`ON UPDATE NOW()`)\n    })\n    .execute()\n\n  await db.schema\n    .createTable('authorisations')\n    .addColumn('provider_type', 'varchar(255)', (col) => col.notNull())\n    .addColumn('provider_user_id', 'varchar(255)', (col) => col.notNull())\n    .addColumn('user_id', 'varchar(255)', (col) => col.notNull())\n    .addPrimaryKeyConstraint('primary_key', ['provider_type', 'provider_user_id', 'user_id'])\n    .addUniqueConstraint('unique_provider_user', ['provider_type', 'provider_user_id'])\n    .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`))\n    .addColumn('updated_at', 'timestamp', (col) => {\n      return col.defaultTo(sql`NOW()`).modifyEnd(sql`ON UPDATE NOW()`)\n    })\n    .execute()\n\n  await db.schema.createIndex('user_email_index').on('user').column('email').execute()\n\n  await db.schema\n    .createIndex('authorisations_user_id_index')\n    .on('authorisations')\n    .column('user_id')\n    .execute()\n\n  await db.schema\n    .createTable('one_time_oauth_code')\n    .addColumn('code', 'varchar(255)', (col) => col.primaryKey())\n    .addColumn('user_id', 'varchar(255)', (col) => col.notNull())\n    .addColumn('access_token', 'varchar(255)', (col) => col.notNull())\n    .addColumn('access_token_expires_at', 'timestamp', (col) => col.notNull())\n    .addColumn('refresh_token', 'varchar(255)', (col) => col.notNull())\n    .addColumn('refresh_token_expires_at', 'timestamp', (col) => col.notNull())\n    .addColumn('expires_at', 'timestamp', (col) => col.notNull())\n    .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql`NOW()`))\n    .addColumn('updated_at', 'timestamp', (col) => {\n      return col.defaultTo(sql`NOW()`).modifyEnd(sql`ON UPDATE NOW()`)\n    })\n    .execute()\n}\n\nexport async function down(db: Kysely<Database>) {\n  await db.schema.dropTable('user').ifExists().execute()\n  await db.schema.dropTable('authorisations').ifExists().execute()\n  await db.schema.dropTable('one_time_oauth_code').ifExists().execute()\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"create-cf-planetscale-app\",\n  \"version\": \"3.0.0\",\n  \"description\": \"Create a Cloudflare workers app for building production ready RESTful APIs using Hono\",\n  \"main\": \"dist/index.mjs\",\n  \"engines\": {\n    \"node\": \">=12.0.0\"\n  },\n  \"bin\": \"bin/createApp.js\",\n  \"repository\": \"https://github.com/OultimoCoder/cloudflare-planetscale-hono-boilerplate.git\",\n  \"author\": \"Ben Louis Armstrong <ben.armstrong22@gmail.com>\",\n  \"license\": \"MIT\",\n  \"keywords\": [\n    \"cloudflare\",\n    \"workers\",\n    \"cloudflare-worker\",\n    \"cloudflare-workers\",\n    \"planetscale\",\n    \"boilerplate\",\n    \"template\",\n    \"starter\",\n    \"example\",\n    \"vitest\",\n    \"hono\",\n    \"api\",\n    \"rest\",\n    \"sql\",\n    \"oauth\",\n    \"jwt\",\n    \"es6\",\n    \"es7\",\n    \"es8\",\n    \"es9\",\n    \"jwt\",\n    \"zod\",\n    \"eslint\",\n    \"prettier\"\n  ],\n  \"scripts\": {\n    \"build\": \"node ./build.js\",\n    \"dev\": \"wrangler dev dist/index.mjs --live-reload --port 8787\",\n    \"tests\": \"npm run build && vitest run\",\n    \"tests:coverage\": \"npm run build && vitest run --coverage --coverage.provider istanbul --coverage.include src/\",\n    \"migrate:test:latest\": \"node --experimental-specifier-resolution=node --loader ts-node/esm ./scripts/migrate.ts test latest\",\n    \"migrate:test:none\": \"node --experimental-specifier-resolution=node --loader ts-node/esm ./scripts/migrate.ts test none\",\n    \"migrate:test:down\": \"node --experimental-specifier-resolution=node --loader ts-node/esm ./scripts/migrate.ts test down\",\n    \"lint\": \"eslint .\",\n    \"lint:fix\": \"eslint . --fix\",\n    \"prettier\": \"prettier --check **/*.ts\",\n    \"prettier:fix\": \"prettier --write **/**/*.ts\",\n    \"prepare\": \"husky\",\n    \"deploy\": \"wrangler publish\"\n  },\n  \"type\": \"module\",\n  \"devDependencies\": {\n    \"@cloudflare/vitest-pool-workers\": \"^0.4.25\",\n    \"@cloudflare/workers-types\": \"^4.20240821.1\",\n    \"@faker-js/faker\": \"^8.4.1\",\n    \"@types/bcryptjs\": \"^2.4.6\",\n    \"@types/eslint__js\": \"^8.42.3\",\n    \"@typescript-eslint/parser\": \"^8.2.0\",\n    \"cross-env\": \"^7.0.3\",\n    \"dotenv\": \"^16.4.5\",\n    \"esbuild\": \"^0.23.1\",\n    \"eslint\": \"^9.9.0\",\n    \"eslint-config-prettier\": \"^9.1.0\",\n    \"eslint-plugin-import-x\": \"^3.1.0\",\n    \"eslint-plugin-node\": \"^11.1.0\",\n    \"eslint-plugin-prettier\": \"^5.2.1\",\n    \"eslint-plugin-vitest\": \"^0.5.4\",\n    \"globals\": \"^15.9.0\",\n    \"husky\": \"^9.1.5\",\n    \"mockdate\": \"^3.0.5\",\n    \"ts-node\": \"^10.9.2\",\n    \"typescript\": \"^5.5.4\",\n    \"typescript-eslint\": \"^8.2.0\",\n    \"vitest\": \"1.5.0\",\n    \"wrangler\": \"^3.72.2\"\n  },\n  \"dependencies\": {\n    \"@aws-sdk/client-ses\": \"^3.637.0\",\n    \"@hono/sentry\": \"^1.2.0\",\n    \"@planetscale/database\": \"^1.19.0\",\n    \"@smithy/types\": \"^3.3.0\",\n    \"@tsndr/cloudflare-worker-jwt\": \"2.5.3\",\n    \"@vitest/coverage-istanbul\": \"^1.5.0\",\n    \"bcryptjs\": \"^2.4.3\",\n    \"dayjs\": \"^1.11.13\",\n    \"hono\": \"^4.5.8\",\n    \"http-status\": \"^1.7.4\",\n    \"kysely\": \"^0.27.4\",\n    \"kysely-planetscale\": \"^1.5\",\n    \"nanoid\": \"^5.0.7\",\n    \"toucan-js\": \"4.0.0\",\n    \"worker-auth-providers\": \"^0.0.13\",\n    \"zod\": \"^3.23.8\",\n    \"zod-validation-error\": \"^3.3.1\"\n  }\n}\n"
  },
  {
    "path": "scripts/migrate.ts",
    "content": "/* eslint no-console: \"off\" */\nimport { promises as fs } from 'fs'\nimport * as path from 'path'\nimport { fileURLToPath } from 'url'\nimport * as dotenv from 'dotenv'\nimport { Migrator, FileMigrationProvider, NO_MIGRATIONS } from 'kysely'\nimport { Kysely } from 'kysely'\nimport { PlanetScaleDialect } from 'kysely-planetscale'\nimport { User } from '../src/models/user.model'\n\nconst envFile = {\n  dev: '.env',\n  test: '.env.test'\n}\n\nconst __filename = fileURLToPath(import.meta.url)\n\ndotenv.config({ path: path.join(path.dirname(__filename), `../${envFile[process.argv[2]]}`) })\n\ninterface Database {\n  user: User\n}\n\nconst db = new Kysely<Database>({\n  dialect: new PlanetScaleDialect({\n    username: process.env.DATABASE_USERNAME,\n    password: process.env.DATABASE_PASSWORD,\n    host: process.env.DATABASE_HOST\n  })\n})\n\nconst migrator = new Migrator({\n  db,\n  provider: new FileMigrationProvider({\n    fs,\n    path,\n    migrationFolder: path.join(path.dirname(__filename), '../migrations')\n  })\n})\n\nasync function migrateToLatest() {\n  const { error, results } = await migrator.migrateToLatest()\n  results?.forEach((it) => {\n    if (it.status === 'Success') {\n      console.log(`migration '${it.migrationName}' was executed successfully`)\n    } else if (it.status === 'Error') {\n      console.error(`failed to execute migration \"${it.migrationName}\"`)\n    }\n  })\n\n  if (error) {\n    console.error('failed to migrate')\n    console.error(error)\n    process.exit(1)\n  }\n\n  await db.destroy()\n}\n\nasync function migrateDown() {\n  const { error, results } = await migrator.migrateDown()\n  results?.forEach((it) => {\n    if (it.status === 'Success') {\n      console.log(`migration '${it.migrationName}' was reverted successfully`)\n    } else if (it.status === 'Error') {\n      console.error(`failed to execute migration \"${it.migrationName}\"`)\n    }\n  })\n\n  if (error) {\n    console.error('failed to migrate')\n    console.error(error)\n    process.exit(1)\n  }\n\n  await db.destroy()\n}\n\nasync function migrateNone() {\n  const { error, results } = await migrator.migrateTo(NO_MIGRATIONS)\n  results?.forEach((it) => {\n    if (it.status === 'Success') {\n      console.log(`migration '${it.migrationName}' was reverted successfully`)\n    } else if (it.status === 'Error') {\n      console.error(`failed to execute migration \"${it.migrationName}\"`)\n    }\n  })\n\n  if (error) {\n    console.error('failed to migrate')\n    console.error(error)\n    process.exit(1)\n  }\n\n  await db.destroy()\n}\n\nconst myArgs = process.argv[3]\n\nif (myArgs === 'down') {\n  await migrateDown()\n} else if (myArgs === 'latest') {\n  await migrateToLatest()\n} else if (myArgs === 'none') {\n  await migrateNone()\n}\n"
  },
  {
    "path": "src/config/authProviders.ts",
    "content": "export const authProviders = {\n  GITHUB: 'github',\n  SPOTIFY: 'spotify',\n  DISCORD: 'discord',\n  GOOGLE: 'google',\n  FACEBOOK: 'facebook',\n  APPLE: 'apple'\n} as const\n"
  },
  {
    "path": "src/config/config.ts",
    "content": "import httpStatus from 'http-status'\nimport { ZodError, z } from 'zod'\nimport { Environment } from '../../bindings'\nimport { ApiError } from '../utils/api-error'\nimport { generateZodErrorMessage } from '../utils/zod'\n\nconst envVarsSchema = z.object({\n  ENV: z.union([z.literal('production'), z.literal('development'), z.literal('test')]),\n  DATABASE_NAME: z.string(),\n  DATABASE_USERNAME: z.string(),\n  DATABASE_PASSWORD: z.string(),\n  DATABASE_HOST: z.string(),\n  JWT_SECRET: z.string(),\n  JWT_ACCESS_EXPIRATION_MINUTES: z.coerce.number().default(30),\n  JWT_REFRESH_EXPIRATION_DAYS: z.coerce.number().default(30),\n  JWT_RESET_PASSWORD_EXPIRATION_MINUTES: z.coerce.number().default(10),\n  JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: z.coerce.number().default(10),\n  AWS_ACCESS_KEY_ID: z.string(),\n  AWS_SECRET_ACCESS_KEY: z.string(),\n  AWS_REGION: z.string(),\n  EMAIL_SENDER: z.string(),\n  OAUTH_WEB_REDIRECT_URL: z.string(),\n  OAUTH_ANDROID_REDIRECT_URL: z.string(),\n  OAUTH_IOS_REDIRECT_URL: z.string(),\n  OAUTH_GITHUB_CLIENT_ID: z.string(),\n  OAUTH_GITHUB_CLIENT_SECRET: z.string(),\n  OAUTH_GOOGLE_CLIENT_ID: z.string(),\n  OAUTH_GOOGLE_CLIENT_SECRET: z.string(),\n  OAUTH_DISCORD_CLIENT_ID: z.string(),\n  OAUTH_DISCORD_CLIENT_SECRET: z.string(),\n  OAUTH_SPOTIFY_CLIENT_ID: z.string(),\n  OAUTH_SPOTIFY_CLIENT_SECRET: z.string(),\n  OAUTH_FACEBOOK_CLIENT_ID: z.string(),\n  OAUTH_FACEBOOK_CLIENT_SECRET: z.string(),\n  OAUTH_APPLE_CLIENT_ID: z.string(),\n  OAUTH_APPLE_PRIVATE_KEY: z.string(),\n  OAUTH_APPLE_KEY_ID: z.string(),\n  OAUTH_APPLE_TEAM_ID: z.string(),\n  OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES: z.coerce.number().default(30),\n  OAUTH_APPLE_REDIRECT_URL: z.string()\n})\n\nexport type EnvVarsSchemaType = z.infer<typeof envVarsSchema>\n\nexport interface Config {\n  env: 'production' | 'development' | 'test'\n  database: {\n    name: string\n    username: string\n    password: string\n    host: string\n  }\n  jwt: {\n    secret: string\n    accessExpirationMinutes: number\n    refreshExpirationDays: number\n    resetPasswordExpirationMinutes: number\n    verifyEmailExpirationMinutes: number\n  }\n  aws: {\n    accessKeyId: string\n    secretAccessKey: string\n    region: string\n  }\n  email: {\n    sender: string\n  }\n  oauth: {\n    platform: {\n      web: {\n        redirectUrl: string\n      }\n      android: {\n        redirectUrl: string\n      }\n      ios: {\n        redirectUrl: string\n      }\n    }\n    provider: {\n      github: {\n        clientId: string\n        clientSecret: string\n      }\n      google: {\n        clientId: string\n        clientSecret: string\n      }\n      spotify: {\n        clientId: string\n        clientSecret: string\n      }\n      discord: {\n        clientId: string\n        clientSecret: string\n      }\n      facebook: {\n        clientId: string\n        clientSecret: string\n      }\n      apple: {\n        clientId: string\n        privateKey: string\n        keyId: string\n        teamId: string\n        jwtAccessExpirationMinutes: number\n        redirectUrl: string\n      }\n    }\n  }\n}\n\nlet config: Config\n\nexport const getConfig = (env: Environment['Bindings']) => {\n  if (config) {\n    return config\n  }\n  let envVars: EnvVarsSchemaType\n  try {\n    envVars = envVarsSchema.parse(env)\n  } catch (err) {\n    if (env.ENV && env.ENV === 'production') {\n      throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, 'Invalid server configuration')\n    }\n    if (err instanceof ZodError) {\n      const errorMessage = generateZodErrorMessage(err)\n      throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, errorMessage)\n    }\n    throw err\n  }\n  config = {\n    env: envVars.ENV,\n    database: {\n      name: envVars.DATABASE_NAME,\n      username: envVars.DATABASE_USERNAME,\n      password: envVars.DATABASE_PASSWORD,\n      host: envVars.DATABASE_HOST\n    },\n    jwt: {\n      secret: envVars.JWT_SECRET,\n      accessExpirationMinutes: envVars.JWT_ACCESS_EXPIRATION_MINUTES,\n      refreshExpirationDays: envVars.JWT_REFRESH_EXPIRATION_DAYS,\n      resetPasswordExpirationMinutes: envVars.JWT_RESET_PASSWORD_EXPIRATION_MINUTES,\n      verifyEmailExpirationMinutes: envVars.JWT_VERIFY_EMAIL_EXPIRATION_MINUTES\n    },\n    aws: {\n      accessKeyId: envVars.AWS_ACCESS_KEY_ID,\n      secretAccessKey: envVars.AWS_SECRET_ACCESS_KEY,\n      region: envVars.AWS_REGION\n    },\n    email: {\n      sender: envVars.EMAIL_SENDER\n    },\n    oauth: {\n      platform: {\n        web: {\n          redirectUrl: envVars.OAUTH_WEB_REDIRECT_URL\n        },\n        android: {\n          redirectUrl: envVars.OAUTH_ANDROID_REDIRECT_URL\n        },\n        ios: {\n          redirectUrl: envVars.OAUTH_IOS_REDIRECT_URL\n        }\n      },\n      provider: {\n        github: {\n          clientId: envVars.OAUTH_GITHUB_CLIENT_ID,\n          clientSecret: envVars.OAUTH_GITHUB_CLIENT_SECRET\n        },\n        google: {\n          clientId: envVars.OAUTH_GOOGLE_CLIENT_ID,\n          clientSecret: envVars.OAUTH_GOOGLE_CLIENT_SECRET\n        },\n        spotify: {\n          clientId: envVars.OAUTH_SPOTIFY_CLIENT_ID,\n          clientSecret: envVars.OAUTH_SPOTIFY_CLIENT_SECRET\n        },\n        discord: {\n          clientId: envVars.OAUTH_DISCORD_CLIENT_ID,\n          clientSecret: envVars.OAUTH_DISCORD_CLIENT_SECRET\n        },\n        facebook: {\n          clientId: envVars.OAUTH_FACEBOOK_CLIENT_ID,\n          clientSecret: envVars.OAUTH_FACEBOOK_CLIENT_SECRET\n        },\n        apple: {\n          clientId: envVars.OAUTH_APPLE_CLIENT_ID,\n          privateKey: envVars.OAUTH_APPLE_PRIVATE_KEY,\n          keyId: envVars.OAUTH_APPLE_KEY_ID,\n          teamId: envVars.OAUTH_APPLE_TEAM_ID,\n          jwtAccessExpirationMinutes: envVars.OAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES,\n          redirectUrl: envVars.OAUTH_APPLE_REDIRECT_URL\n        }\n      }\n    }\n  }\n  return config\n}\n"
  },
  {
    "path": "src/config/database.ts",
    "content": "import { Kysely } from 'kysely'\nimport { PlanetScaleDialect } from 'kysely-planetscale'\nimport { AuthProviderTable } from '../tables/oauth.table'\nimport { OneTimeOauthCodeTable } from '../tables/one-time-oauth-code.table'\nimport { UserTable } from '../tables/user.table'\nimport { Config } from './config'\n\nlet dbClient: Kysely<Database>\n\nexport interface Database {\n  user: UserTable\n  authorisations: AuthProviderTable\n  one_time_oauth_code: OneTimeOauthCodeTable\n}\n\nexport const getDBClient = (databaseConfig: Config['database']): Kysely<Database> => {\n  dbClient =\n    dbClient ||\n    new Kysely<Database>({\n      dialect: new PlanetScaleDialect({\n        username: databaseConfig.username,\n        password: databaseConfig.password,\n        host: databaseConfig.host,\n        fetch: (url, init) => {\n          // TODO: REMOVE.\n          // Remove cache header\n          // https://github.com/cloudflare/workerd/issues/698\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          delete (init as any)['cache']\n          return fetch(url, init)\n        }\n      })\n    })\n  return dbClient\n}\n"
  },
  {
    "path": "src/config/roles.ts",
    "content": "export const roleRights = {\n  user: [],\n  admin: ['getUsers', 'manageUsers']\n} as const\n\nexport const roles = Object.keys(roleRights) as Role[]\n\nexport type Permission = (typeof roleRights)[keyof typeof roleRights][number]\nexport type Role = keyof typeof roleRights\n"
  },
  {
    "path": "src/config/tokens.ts",
    "content": "export const tokenTypes = {\n  ACCESS: 'access',\n  REFRESH: 'refresh',\n  RESET_PASSWORD: 'resetPassword',\n  VERIFY_EMAIL: 'verifyEmail'\n} as const\n\nexport type TokenType = (typeof tokenTypes)[keyof typeof tokenTypes]\n"
  },
  {
    "path": "src/controllers/auth/auth.controller.ts",
    "content": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { Environment } from '../../../bindings'\nimport { getConfig } from '../../config/config'\nimport * as authService from '../../services/auth.service'\nimport * as emailService from '../../services/email.service'\nimport * as tokenService from '../../services/token.service'\nimport * as userService from '../../services/user.service'\nimport { ApiError } from '../../utils/api-error'\nimport * as authValidation from '../../validations/auth.validation'\n\nexport const register: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const body = await authValidation.register.parseAsync(bodyParse)\n  const user = await authService.register(body, config.database)\n  const tokens = await tokenService.generateAuthTokens(user, config.jwt)\n  return c.json({ user, tokens }, httpStatus.CREATED)\n}\n\nexport const login: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const { email, password } = authValidation.login.parse(bodyParse)\n  const user = await authService.loginUserWithEmailAndPassword(email, password, config.database)\n  const tokens = await tokenService.generateAuthTokens(user, config.jwt)\n  return c.json({ user, tokens }, httpStatus.OK)\n}\n\nexport const refreshTokens: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const { refresh_token } = authValidation.refreshTokens.parse(bodyParse)\n  const tokens = await authService.refreshAuth(refresh_token, config)\n  return c.json({ ...tokens }, httpStatus.OK)\n}\n\nexport const forgotPassword: Handler<Environment> = async (c) => {\n  const bodyParse = await c.req.json()\n  const config = getConfig(c.env)\n  const { email } = authValidation.forgotPassword.parse(bodyParse)\n  const user = await userService.getUserByEmail(email, config.database)\n  // Don't let bad actors know if the email is registered by throwing if the user exists\n  if (user) {\n    const resetPasswordToken = await tokenService.generateResetPasswordToken(user, config.jwt)\n    await emailService.sendResetPasswordEmail(\n      user.email,\n      { name: user.name || '', token: resetPasswordToken },\n      config\n    )\n  }\n  c.status(httpStatus.NO_CONTENT)\n  return c.body(null)\n}\n\nexport const resetPassword: Handler<Environment> = async (c) => {\n  const queryParse = c.req.query()\n  const bodyParse = await c.req.json()\n  const config = getConfig(c.env)\n  const { query, body } = await authValidation.resetPassword.parseAsync({\n    query: queryParse,\n    body: bodyParse\n  })\n  await authService.resetPassword(query.token, body.password, config)\n  c.status(httpStatus.NO_CONTENT)\n  return c.body(null)\n}\n\nexport const sendVerificationEmail: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const payload = c.get('payload')\n  const userId = payload.sub\n  if (!userId) {\n    throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')\n  }\n  // Don't let bad actors know if the email is registered by returning an error if the email\n  // is already verified\n  try {\n    const user = await userService.getUserById(userId, config.database)\n    if (!user || user.is_email_verified) {\n      throw new Error()\n    }\n    const verifyEmailToken = await tokenService.generateVerifyEmailToken(user, config.jwt)\n    await emailService.sendVerificationEmail(\n      user.email,\n      { name: user.name || '', token: verifyEmailToken },\n      config\n    )\n  } catch {}\n  c.status(httpStatus.NO_CONTENT)\n  return c.body(null)\n}\n\nexport const verifyEmail: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const queryParse = c.req.query()\n  const { token } = authValidation.verifyEmail.parse(queryParse)\n  await authService.verifyEmail(token, config)\n  c.status(httpStatus.NO_CONTENT)\n  return c.body(null)\n}\n\nexport const getAuthorisations: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const payload = c.get('payload')\n  if (!payload.sub) {\n    throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')\n  }\n  const userId = payload.sub\n  const authorisations = await userService.getAuthorisations(userId, config.database)\n  return c.json(authorisations, httpStatus.OK)\n}\n"
  },
  {
    "path": "src/controllers/auth/oauth/apple.controller.ts",
    "content": "// TODO: Handle users using private email relay\n// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/\n// authenticating_users_with_sign_in_with_apple\n// Also handle users without email\n// refactor\nimport { decode } from '@tsndr/cloudflare-worker-jwt'\nimport { Handler } from 'hono'\nimport type { StatusCode } from 'hono/utils/http-status'\nimport httpStatus from 'http-status'\nimport { apple } from 'worker-auth-providers'\nimport { Environment } from '../../../../bindings'\nimport { authProviders } from '../../../config/authProviders'\nimport { Config, getConfig } from '../../../config/config'\nimport { AppleUser } from '../../../models/oauth/apple-user.model'\nimport * as authService from '../../../services/auth.service'\nimport { getIdTokenFromCode } from '../../../services/oauth/apple.service'\nimport * as tokenService from '../../../services/token.service'\nimport { ApiError } from '../../../utils/api-error'\nimport * as authValidation from '../../../validations/auth.validation'\nimport { deleteOauthLink, getRedirectUrl, parseState } from './oauth.controller'\n\ntype AppleJWT = {\n  iss: string\n  aud: string\n  exp: number\n  iat: number\n  sub: string\n  at_hash: string\n  email: string\n  email_verified: string\n  is_private_email: string\n  auth_time: number\n  nonce_supported: boolean\n}\n\nconst getAppleUser = async (code: string | null, config: Config) => {\n  if (!code) {\n    throw new ApiError(httpStatus.BAD_REQUEST as StatusCode, 'Bad request')\n  }\n  const appleClientSecret = await apple.convertPrivateKeyToClientSecret({\n    privateKey: config.oauth.provider.apple.privateKey,\n    keyIdentifier: config.oauth.provider.apple.keyId,\n    teamId: config.oauth.provider.apple.teamId,\n    clientId: config.oauth.provider.apple.clientId,\n    expAfter: config.oauth.provider.apple.jwtAccessExpirationMinutes * 60\n  })\n  const idToken = await getIdTokenFromCode(\n    code,\n    config.oauth.provider.apple.clientId,\n    appleClientSecret,\n    config.oauth.provider.apple.redirectUrl\n  )\n  const userData = decode(idToken).payload as AppleJWT\n  if (!userData.email) {\n    throw new ApiError(httpStatus.UNAUTHORIZED, 'Unauthorized')\n  }\n  const appleUser = new AppleUser(userData)\n  return appleUser\n}\n\nexport const appleRedirect: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const { state } = authValidation.oauthRedirect.parse(c.req.query())\n  parseState(state)\n  const location = await apple.redirect({\n    options: {\n      clientId: config.oauth.provider.apple.clientId,\n      redirectTo: config.oauth.provider.apple.redirectUrl,\n      scope: ['email'],\n      responseMode: 'form_post',\n      state: state\n    }\n  })\n  return c.redirect(location, httpStatus.FOUND)\n}\n\nexport const appleCallback: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const formData = await c.req.formData()\n  const state = formData.get('state')\n  if (!state) {\n    const redirect = new URL('?error=Something went wrong', config.oauth.platform.web.redirectUrl)\n      .href\n    return c.redirect(redirect, httpStatus.FOUND)\n  }\n  // Set a base redirect url to web in case of no platform info being passed\n  let redirectBase = config.oauth.platform.web.redirectUrl\n  try {\n    redirectBase = getRedirectUrl(state, config)\n    const appleUser = await getAppleUser(formData.get('code'), config)\n    const user = await authService.loginOrCreateUserWithOauth(appleUser, config.database)\n    const tokens = await tokenService.generateAuthTokens(user, config.jwt)\n    const oneTimeCode = await tokenService.createOneTimeOauthCode(user.id, tokens, config)\n    const redirect = new URL(`?oneTimeCode=${oneTimeCode}&state=${state}`, redirectBase).href\n    return c.redirect(redirect, httpStatus.FOUND)\n  } catch (error) {\n    const message = error instanceof ApiError ? error.message : 'Something went wrong'\n    const redirect = new URL(`?error=${message}&state=${state}`, redirectBase).href\n    return c.redirect(redirect, httpStatus.FOUND)\n  }\n}\n\nexport const linkApple: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const payload = c.get('payload')\n  const userId = payload.sub\n  if (!userId) {\n    throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')\n  }\n  const bodyParse = await c.req.json()\n  const { code } = authValidation.linkApple.parse(bodyParse)\n  const appleUser = await getAppleUser(code, config)\n  await authService.linkUserWithOauth(userId, appleUser, config.database)\n  c.status(httpStatus.NO_CONTENT as StatusCode)\n  return c.body(null)\n}\n\nexport const deleteAppleLink: Handler<Environment> = async (c) => {\n  return deleteOauthLink(c, authProviders.APPLE)\n}\n"
  },
  {
    "path": "src/controllers/auth/oauth/discord.controller.ts",
    "content": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { discord } from 'worker-auth-providers'\nimport { Environment } from '../../../../bindings'\nimport { authProviders } from '../../../config/authProviders'\nimport { getConfig } from '../../../config/config'\nimport { DiscordUserType } from '../../../types/oauth.types'\nimport * as authValidation from '../../../validations/auth.validation'\nimport {\n  oauthCallback,\n  oauthLink,\n  deleteOauthLink,\n  validateCallbackBody,\n  getRedirectUrl\n} from './oauth.controller'\n\nexport const discordRedirect: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const { state } = authValidation.oauthRedirect.parse(c.req.query())\n  const redirectUrl = getRedirectUrl(state, config)\n  const location = await discord.redirect({\n    options: {\n      clientId: config.oauth.provider.discord.clientId,\n      redirectUrl: redirectUrl,\n      state: state,\n      scope: 'identify email'\n    }\n  })\n  return c.redirect(location, httpStatus.FOUND)\n}\n\nexport const discordCallback: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const { platform, code } = authValidation.oauthCallback.parse(bodyParse)\n  const redirectUrl = config.oauth.platform[platform].redirectUrl\n  const request = await validateCallbackBody(c, code)\n  const oauthRequest = discord.users({\n    options: {\n      clientId: config.oauth.provider.discord.clientId,\n      clientSecret: config.oauth.provider.discord.clientSecret,\n      redirectUrl: redirectUrl\n    },\n    request\n  }) as Promise<{ user: DiscordUserType; tokens: unknown }>\n  return oauthCallback<typeof authProviders.DISCORD>(c, oauthRequest, authProviders.DISCORD)\n}\n\nexport const linkDiscord: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const { platform, code } = authValidation.oauthCallback.parse(bodyParse)\n  const redirectUrl = config.oauth.platform[platform].redirectUrl\n  const request = await validateCallbackBody(c, code)\n  const oauthRequest = discord.users({\n    options: {\n      clientId: config.oauth.provider.discord.clientId,\n      clientSecret: config.oauth.provider.discord.clientSecret,\n      redirectUrl: redirectUrl\n    },\n    request\n  }) as Promise<{ user: DiscordUserType; tokens: unknown }>\n  return oauthLink<typeof authProviders.DISCORD>(c, oauthRequest, authProviders.DISCORD)\n}\n\nexport const deleteDiscordLink: Handler<Environment> = async (c) => {\n  return deleteOauthLink(c, authProviders.DISCORD)\n}\n"
  },
  {
    "path": "src/controllers/auth/oauth/facebook.controller.ts",
    "content": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { facebook } from 'worker-auth-providers'\nimport { Environment } from '../../../../bindings'\nimport { authProviders } from '../../../config/authProviders'\nimport { getConfig } from '../../../config/config'\nimport * as facebookService from '../../../services/oauth/facebook.service'\nimport * as authValidation from '../../../validations/auth.validation'\nimport {\n  oauthCallback,\n  oauthLink,\n  deleteOauthLink,\n  validateCallbackBody,\n  getRedirectUrl\n} from './oauth.controller'\n\nexport const facebookRedirect: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const { state } = authValidation.oauthRedirect.parse(c.req.query())\n  const redirectUrl = getRedirectUrl(state, config)\n  const location = await facebookService.redirect({\n    clientId: config.oauth.provider.facebook.clientId,\n    redirectUrl: redirectUrl,\n    state: state\n  })\n  return c.redirect(location, httpStatus.FOUND)\n}\n\nexport const facebookCallback: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const { platform, code } = authValidation.oauthCallback.parse(bodyParse)\n  const redirectUrl = config.oauth.platform[platform].redirectUrl\n  const request = await validateCallbackBody(c, code)\n  const oauthRequest = facebook.users({\n    options: {\n      clientId: config.oauth.provider.facebook.clientId,\n      clientSecret: config.oauth.provider.facebook.clientSecret,\n      redirectUrl: redirectUrl\n    },\n    request\n  })\n  return oauthCallback<typeof authProviders.FACEBOOK>(c, oauthRequest, authProviders.FACEBOOK)\n}\n\nexport const linkFacebook: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const { platform, code } = authValidation.oauthCallback.parse(bodyParse)\n  const request = await validateCallbackBody(c, code)\n  const redirectUrl = config.oauth.platform[platform].redirectUrl\n  const oauthRequest = facebook.users({\n    options: {\n      clientId: config.oauth.provider.facebook.clientId,\n      clientSecret: config.oauth.provider.facebook.clientSecret,\n      redirectUrl: redirectUrl\n    },\n    request\n  })\n  return oauthLink<typeof authProviders.FACEBOOK>(c, oauthRequest, authProviders.FACEBOOK)\n}\n\nexport const deleteFacebookLink: Handler<Environment> = async (c) => {\n  return deleteOauthLink(c, authProviders.FACEBOOK)\n}\n"
  },
  {
    "path": "src/controllers/auth/oauth/github.controller.ts",
    "content": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { github } from 'worker-auth-providers'\nimport { Environment } from '../../../../bindings'\nimport { authProviders } from '../../../config/authProviders'\nimport { getConfig } from '../../../config/config'\nimport * as githubService from '../../../services/oauth/github.service'\nimport * as authValidation from '../../../validations/auth.validation'\nimport {\n  oauthCallback,\n  oauthLink,\n  deleteOauthLink,\n  validateCallbackBody,\n  getRedirectUrl\n} from './oauth.controller'\n\nexport const githubRedirect: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const { state } = authValidation.oauthRedirect.parse(c.req.query())\n  const redirectUrl = getRedirectUrl(state, config)\n  const location = await githubService.redirect({\n    clientId: config.oauth.provider.github.clientId,\n    redirectTo: redirectUrl,\n    state: state\n  })\n  return c.redirect(location, httpStatus.FOUND)\n}\n\nexport const githubCallback: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const { platform, code } = authValidation.oauthCallback.parse(bodyParse)\n  const request = await validateCallbackBody(c, code)\n  const redirectUrl = config.oauth.platform[platform].redirectUrl\n  const oauthRequest = github.users({\n    options: {\n      clientId: config.oauth.provider.github.clientId,\n      clientSecret: config.oauth.provider.github.clientSecret,\n      redirectUrl: redirectUrl\n    },\n    request\n  })\n  return oauthCallback<typeof authProviders.GITHUB>(c, oauthRequest, authProviders.GITHUB)\n}\n\nexport const linkGithub: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const { platform, code } = authValidation.oauthCallback.parse(bodyParse)\n  const request = await validateCallbackBody(c, code)\n  const redirectUrl = config.oauth.platform[platform].redirectUrl\n  const oauthRequest = github.users({\n    options: {\n      clientId: config.oauth.provider.github.clientId,\n      clientSecret: config.oauth.provider.github.clientSecret,\n      redirectUrl: redirectUrl\n    },\n    request\n  })\n  return oauthLink(c, oauthRequest, authProviders.GITHUB)\n}\n\nexport const deleteGithubLink: Handler<Environment> = async (c) => {\n  return deleteOauthLink(c, authProviders.GITHUB)\n}\n"
  },
  {
    "path": "src/controllers/auth/oauth/google.controller.ts",
    "content": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { google } from 'worker-auth-providers'\nimport { Environment } from '../../../../bindings'\nimport { authProviders } from '../../../config/authProviders'\nimport { getConfig } from '../../../config/config'\nimport { GoogleUserType } from '../../../types/oauth.types'\nimport * as authValidation from '../../../validations/auth.validation'\nimport {\n  oauthCallback,\n  oauthLink,\n  deleteOauthLink,\n  validateCallbackBody,\n  getRedirectUrl\n} from './oauth.controller'\n\nexport const googleRedirect: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const { state } = authValidation.oauthRedirect.parse(c.req.query())\n  const redirectUrl = getRedirectUrl(state, config)\n  const location = await google.redirect({\n    options: {\n      clientId: config.oauth.provider.google.clientId,\n      redirectUrl: redirectUrl,\n      state: state\n    }\n  })\n  return c.redirect(location, httpStatus.FOUND)\n}\n\nexport const googleCallback: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const { platform, code } = authValidation.oauthCallback.parse(bodyParse)\n  const request = await validateCallbackBody(c, code)\n  const redirectUrl = config.oauth.platform[platform].redirectUrl\n  const oauthRequest = google.users({\n    options: {\n      clientId: config.oauth.provider.google.clientId,\n      clientSecret: config.oauth.provider.google.clientSecret,\n      redirectUrl: redirectUrl\n    },\n    request\n  }) as Promise<{ user: GoogleUserType; tokens: unknown }>\n  return oauthCallback<typeof authProviders.GOOGLE>(c, oauthRequest, authProviders.GOOGLE)\n}\n\nexport const linkGoogle: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const { platform, code } = authValidation.oauthCallback.parse(bodyParse)\n  const request = await validateCallbackBody(c, code)\n  const redirectUrl = config.oauth.platform[platform].redirectUrl\n  const oauthRequest = google.users({\n    options: {\n      clientId: config.oauth.provider.google.clientId,\n      clientSecret: config.oauth.provider.google.clientSecret,\n      redirectUrl: redirectUrl\n    },\n    request\n  }) as Promise<{ user: GoogleUserType; tokens: unknown }>\n  return oauthLink<typeof authProviders.GOOGLE>(c, oauthRequest, authProviders.GOOGLE)\n}\n\nexport const deleteGoogleLink: Handler<Environment> = async (c) => {\n  return deleteOauthLink(c, authProviders.GOOGLE)\n}\n"
  },
  {
    "path": "src/controllers/auth/oauth/oauth.controller.ts",
    "content": "import { Context, Handler } from 'hono'\nimport type { StatusCode } from 'hono/utils/http-status'\nimport httpStatus from 'http-status'\nimport { Environment } from '../../../../bindings'\nimport { Config, getConfig } from '../../../config/config'\nimport { providerUserFactory } from '../../../factories/oauth.factory'\nimport { OAuthUserModel } from '../../../models/oauth/oauth-base.model'\nimport * as authService from '../../../services/auth.service'\nimport * as tokenService from '../../../services/token.service'\nimport * as userService from '../../../services/user.service'\nimport { AuthProviderType, OauthUserTypes } from '../../../types/oauth.types'\nimport { ApiError } from '../../../utils/api-error'\nimport * as authValidation from '../../../validations/auth.validation'\n\ntype State = {\n  platform: 'web' | 'android' | 'ios'\n}\n\nexport const parseState = (state: string) => {\n  try {\n    const decodedState = JSON.parse(atob(state)) as State\n    authValidation.stateValidation.parse(decodedState)\n    return decodedState\n  } catch {\n    throw new ApiError(httpStatus.BAD_REQUEST as StatusCode, 'Bad request')\n  }\n}\n\nexport const getRedirectUrl = (state: string, config: Config) => {\n  try {\n    const decodedState = parseState(state)\n    const platform = decodedState.platform\n    return config.oauth.platform[platform].redirectUrl\n  } catch {\n    throw new ApiError(httpStatus.BAD_REQUEST as StatusCode, 'Bad request')\n  }\n}\n\nexport const oauthCallback = async <T extends AuthProviderType>(\n  c: Context<Environment>,\n  oauthRequest: Promise<{ user: OauthUserTypes[T]; tokens: unknown }>,\n  providerType: T\n): Promise<Response> => {\n  const config = getConfig(c.env)\n  let providerUser: OAuthUserModel\n  try {\n    const result = await oauthRequest\n    const UserModel = providerUserFactory[providerType]\n    providerUser = new UserModel(result.user)\n  } catch {\n    throw new ApiError(httpStatus.UNAUTHORIZED as StatusCode, 'Unauthorized')\n  }\n  const user = await authService.loginOrCreateUserWithOauth(providerUser, config.database)\n  const tokens = await tokenService.generateAuthTokens(user, config.jwt)\n  return c.json({ user, tokens }, httpStatus.OK as StatusCode)\n}\n\nexport const oauthLink = async <T extends AuthProviderType>(\n  c: Context<Environment>,\n  oauthRequest: Promise<{ user: OauthUserTypes[T]; tokens: unknown }>,\n  providerType: T\n): Promise<Response> => {\n  const payload = c.get('payload')\n  const userId = payload.sub\n  if (!userId) {\n    throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')\n  }\n  const config = getConfig(c.env)\n  let providerUser: OAuthUserModel\n  try {\n    const result = await oauthRequest\n    const UserModel = providerUserFactory[providerType]\n    providerUser = new UserModel(result.user)\n  } catch {\n    throw new ApiError(httpStatus.UNAUTHORIZED as StatusCode, 'Unauthorized')\n  }\n  await authService.linkUserWithOauth(userId, providerUser, config.database)\n  c.status(httpStatus.NO_CONTENT as StatusCode)\n  return c.body(null)\n}\n\nexport const deleteOauthLink = async (\n  c: Context<Environment>,\n  provider: AuthProviderType\n): Promise<Response> => {\n  const payload = c.get('payload')\n  const userId = payload.sub\n  if (!userId) {\n    throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')\n  }\n  const config = getConfig(c.env)\n  await authService.deleteOauthLink(userId, provider, config.database)\n  c.status(httpStatus.NO_CONTENT as StatusCode)\n  return c.body(null)\n}\n\nexport const validateCallbackBody = async (\n  c: Context<Environment>,\n  code: string\n): Promise<Request> => {\n  const url = new URL(c.req.url)\n  url.searchParams.set('code', code)\n  const request = new Request(url.toString())\n  return request\n}\n\nexport const validateOauthOneTimeCode: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const { code } = authValidation.validateOneTimeCode.parse(bodyParse)\n  const oauthCode = await tokenService.getOneTimeOauthCode(code, config)\n  const user = await userService.getUserById(oauthCode.user_id, config.database)\n  const tokenResponse = {\n    access: {\n      token: oauthCode.access_token,\n      expires: oauthCode.access_token_expires_at\n    },\n    refresh: {\n      token: oauthCode.refresh_token,\n      expires: oauthCode.refresh_token_expires_at\n    }\n  }\n  return c.json({ user, tokens: tokenResponse }, httpStatus.OK as StatusCode)\n}\n"
  },
  {
    "path": "src/controllers/auth/oauth/spotify.controller.ts",
    "content": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { spotify } from 'worker-auth-providers'\nimport { Environment } from '../../../../bindings'\nimport { authProviders } from '../../../config/authProviders'\nimport { getConfig } from '../../../config/config'\nimport * as spotifyService from '../../../services/oauth/spotify.service'\nimport { SpotifyUserType } from '../../../types/oauth.types'\nimport * as authValidation from '../../../validations/auth.validation'\nimport {\n  oauthCallback,\n  oauthLink,\n  deleteOauthLink,\n  validateCallbackBody,\n  getRedirectUrl\n} from './oauth.controller'\n\nexport const spotifyRedirect: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const { state } = authValidation.oauthRedirect.parse(c.req.query())\n  const redirectUrl = getRedirectUrl(state, config)\n  const location = await spotifyService.redirect({\n    clientId: config.oauth.provider.spotify.clientId,\n    redirectUrl: redirectUrl,\n    state: state,\n    scope: 'user-read-email'\n  })\n  return c.redirect(location, httpStatus.FOUND)\n}\n\nexport const spotifyCallback: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const { platform, code } = authValidation.oauthCallback.parse(bodyParse)\n  const request = await validateCallbackBody(c, code)\n  const redirectUrl = config.oauth.platform[platform].redirectUrl\n  const oauthRequest = spotify.users({\n    options: {\n      clientId: config.oauth.provider.spotify.clientId,\n      clientSecret: config.oauth.provider.spotify.clientSecret,\n      redirectUrl: redirectUrl\n    },\n    request\n  }) as Promise<{ user: SpotifyUserType; tokens: unknown }>\n  return oauthCallback<typeof authProviders.SPOTIFY>(c, oauthRequest, authProviders.SPOTIFY)\n}\n\nexport const linkSpotify: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const { platform, code } = authValidation.oauthCallback.parse(bodyParse)\n  const request = await validateCallbackBody(c, code)\n  const redirectUrl = config.oauth.platform[platform].redirectUrl\n  const oauthRequest = spotify.users({\n    options: {\n      clientId: config.oauth.provider.spotify.clientId,\n      clientSecret: config.oauth.provider.spotify.clientSecret,\n      redirectUrl: redirectUrl\n    },\n    request\n  }) as Promise<{ user: SpotifyUserType; tokens: unknown }>\n  return oauthLink<typeof authProviders.SPOTIFY>(c, oauthRequest, authProviders.SPOTIFY)\n}\n\nexport const deleteSpotifyLink: Handler<Environment> = async (c) => {\n  return deleteOauthLink(c, authProviders.SPOTIFY)\n}\n"
  },
  {
    "path": "src/controllers/user.controller.ts",
    "content": "import { Handler } from 'hono'\nimport httpStatus from 'http-status'\nimport { Environment } from '../../bindings'\nimport { getConfig } from '../config/config'\nimport * as userService from '../services/user.service'\nimport { ApiError } from '../utils/api-error'\nimport * as userValidation from '../validations/user.validation'\n\nexport const createUser: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const bodyParse = await c.req.json()\n  const body = await userValidation.createUser.parseAsync(bodyParse)\n  const user = await userService.createUser(body, config.database)\n  return c.json(user, httpStatus.CREATED)\n}\n\nexport const getUsers: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const queryParse = c.req.query()\n  const query = userValidation.getUsers.parse(queryParse)\n  const filter = { email: query.email }\n  const options = { sortBy: query.sort_by, limit: query.limit, page: query.page }\n  const result = await userService.queryUsers(filter, options, config.database)\n  return c.json(result, httpStatus.OK)\n}\n\nexport const getUser: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const paramsParse = c.req.param()\n  const params = userValidation.getUser.parse(paramsParse)\n  const user = await userService.getUserById(params.userId, config.database)\n  if (!user) {\n    throw new ApiError(httpStatus.NOT_FOUND, 'User not found')\n  }\n  return c.json(user, httpStatus.OK)\n}\n\nexport const updateUser: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const paramsParse = c.req.param()\n  const bodyParse = await c.req.json()\n  const { params, body } = userValidation.updateUser.parse({ params: paramsParse, body: bodyParse })\n  const user = await userService.updateUserById(params.userId, body, config.database)\n  return c.json(user, httpStatus.OK)\n}\n\nexport const deleteUser: Handler<Environment> = async (c) => {\n  const config = getConfig(c.env)\n  const paramsParse = c.req.param()\n  const params = userValidation.deleteUser.parse(paramsParse)\n  await userService.deleteUserById(params.userId, config.database)\n  c.status(httpStatus.NO_CONTENT)\n  return c.body(null)\n}\n"
  },
  {
    "path": "src/durable-objects/rate-limiter.do.ts",
    "content": "import dayjs from 'dayjs'\nimport { Context, Hono } from 'hono'\nimport httpStatus from 'http-status'\nimport { z, ZodError } from 'zod'\nimport { fromError } from 'zod-validation-error'\nimport { Environment } from '../../bindings'\n\ninterface Config {\n  scope: string\n  key: string\n  limit: number\n  interval: number\n}\n\nconst configValidation = z.object({\n  scope: z.string(),\n  key: z.string(),\n  limit: z.number().int().positive(),\n  interval: z.number().int().positive()\n})\n\nexport class RateLimiter {\n  state: DurableObjectState\n  env: Environment['Bindings']\n  app: Hono = new Hono()\n\n  constructor(state: DurableObjectState, env: Environment['Bindings']) {\n    this.state = state\n    this.env = env\n\n    this.app.post('/', async (c) => {\n      await this.setAlarm()\n      let config\n      try {\n        config = await this.getConfig(c)\n      } catch (err: unknown) {\n        let errorMessage\n        if (err instanceof ZodError) {\n          errorMessage = fromError(err)\n        }\n        return c.json(\n          {\n            statusCode: httpStatus.BAD_REQUEST,\n            error: errorMessage\n          },\n          httpStatus.BAD_REQUEST\n        )\n      }\n      const rate = await this.calculateRate(config)\n      const blocked = this.isRateLimited(rate, config.limit)\n      const headers = this.getHeaders(blocked, config)\n      const remaining = blocked ? 0 : Math.floor(config.limit - rate - 1)\n      // If the remaining requests is negative set it to 0 to indicate 100% throughput\n      const remainingHeader = remaining >= 0 ? remaining : 0\n      return c.json(\n        {\n          blocked,\n          remaining: remainingHeader,\n          expires: headers.expires\n        },\n        httpStatus.OK,\n        headers\n      )\n    })\n  }\n\n  async alarm() {\n    const values = await this.state.storage.list()\n    for await (const [key, _value] of values) {\n      const [_scope, _key, _limit, interval, timestamp] = key.split('|')\n      const currentWindow = Math.floor(this.nowUnix() / parseInt(interval))\n      const timestampLessThan = currentWindow - 2 // expire all keys after 2 intervals have passed\n      if (parseInt(timestamp) < timestampLessThan) {\n        await this.state.storage.delete(key)\n      }\n    }\n  }\n\n  async setAlarm() {\n    const alarm = await this.state.storage.getAlarm()\n    if (!alarm) {\n      this.state.storage.setAlarm(dayjs().add(6, 'hours').toDate())\n    }\n  }\n\n  async getConfig(c: Context) {\n    const body = await c.req.json<Config>()\n    const config = configValidation.parse(body)\n    return config\n  }\n\n  async incrementRequestCount(key: string) {\n    const currentRequestCount = await this.getRequestCount(key)\n    await this.state.storage.put(key, currentRequestCount + 1)\n  }\n\n  async getRequestCount(key: string): Promise<number> {\n    return parseInt((await this.state.storage.get(key)) as string) || 0\n  }\n\n  nowUnix() {\n    return dayjs().unix()\n  }\n\n  async calculateRate(config: Config) {\n    const keyPrefix = `${config.scope}|${config.key}|${config.limit}|${config.interval}`\n    const currentWindow = Math.floor(this.nowUnix() / config.interval)\n    const distanceFromLastWindow = this.nowUnix() % config.interval\n    const currentKey = `${keyPrefix}|${currentWindow}`\n    const previousKey = `${keyPrefix}|${currentWindow - 1}`\n    const currentCount = await this.getRequestCount(currentKey)\n    const previousCount = (await this.getRequestCount(previousKey)) || 0\n    const rate =\n      (previousCount * (config.interval - distanceFromLastWindow)) / config.interval + currentCount\n    if (!this.isRateLimited(rate, config.limit)) {\n      await this.incrementRequestCount(currentKey)\n    }\n    return rate\n  }\n\n  isRateLimited(rate: number, limit: number) {\n    return rate >= limit\n  }\n\n  getHeaders(blocked: boolean, config: Config) {\n    const expires = this.expirySeconds(config)\n    const retryAfter = this.retryAfter(expires)\n    const headers: { expires: string; 'cache-control'?: string } = {\n      expires: retryAfter.toString()\n    }\n    if (!blocked) {\n      return headers\n    }\n    headers['cache-control'] = `public, max-age=${expires}, s-maxage=${expires}, must-revalidate`\n    return headers\n  }\n\n  expirySeconds(config: Config) {\n    const currentWindowStart = Math.floor(this.nowUnix() / config.interval)\n    const currentWindowEnd = currentWindowStart + 1\n    const secondsRemaining = currentWindowEnd * config.interval - this.nowUnix()\n    return secondsRemaining\n  }\n\n  retryAfter(expires: number) {\n    return dayjs().add(expires, 'seconds').toString()\n  }\n\n  async fetch(request: Request): Promise<Response> {\n    return this.app.fetch(request)\n  }\n}\n"
  },
  {
    "path": "src/factories/oauth.factory.ts",
    "content": "import { AppleUser } from '../models/oauth/apple-user.model'\nimport { DiscordUser } from '../models/oauth/discord-user.model'\nimport { FacebookUser } from '../models/oauth/facebook-user.model'\nimport { GithubUser } from '../models/oauth/github-user.model'\nimport { GoogleUser } from '../models/oauth/google-user.model'\nimport { SpotifyUser } from '../models/oauth/spotify-user.model'\nimport { ProviderUserMapping } from '../types/oauth.types'\n\nexport const providerUserFactory: ProviderUserMapping = {\n  facebook: FacebookUser,\n  discord: DiscordUser,\n  google: GoogleUser,\n  spotify: SpotifyUser,\n  apple: AppleUser,\n  github: GithubUser\n}\n"
  },
  {
    "path": "src/index.ts",
    "content": "import { sentry } from '@hono/sentry'\nimport { Hono } from 'hono'\nimport { cors } from 'hono/cors'\nimport httpStatus from 'http-status'\nimport { Environment } from '../bindings'\nimport { errorHandler } from './middlewares/error'\nimport { defaultRoutes } from './routes'\nimport { ApiError } from './utils/api-error'\nexport { RateLimiter } from './durable-objects/rate-limiter.do'\n\nconst app = new Hono<Environment>()\n\napp.use('*', sentry())\napp.use('*', cors())\n\napp.notFound(() => {\n  throw new ApiError(httpStatus.NOT_FOUND, 'Not found')\n})\n\napp.onError(errorHandler)\n\ndefaultRoutes.forEach((route) => {\n  app.route(`${route.path}`, route.route)\n})\n\nexport default app\n"
  },
  {
    "path": "src/middlewares/auth.ts",
    "content": "import jwt, { JwtPayload } from '@tsndr/cloudflare-worker-jwt'\nimport { MiddlewareHandler } from 'hono'\nimport httpStatus from 'http-status'\nimport { Environment } from '../../bindings'\nimport { getConfig } from '../config/config'\nimport { roleRights, Permission, Role } from '../config/roles'\nimport { tokenTypes } from '../config/tokens'\nimport { getUserById } from '../services/user.service'\nimport { ApiError } from '../utils/api-error'\n\nconst authenticate = async (jwtToken: string, secret: string) => {\n  let authorized = false\n  let payload\n  try {\n    authorized = await jwt.verify(jwtToken, secret)\n    const decoded = jwt.decode(jwtToken)\n    payload = decoded.payload as JwtPayload\n    authorized = authorized && payload.type === tokenTypes.ACCESS\n  } catch {}\n  return { authorized, payload }\n}\n\nexport const auth =\n  (...requiredRights: Permission[]): MiddlewareHandler<Environment> =>\n  async (c, next) => {\n    const credentials = c.req.raw.headers.get('Authorization')\n    const config = getConfig(c.env)\n    if (!credentials) {\n      throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')\n    }\n\n    const parts = credentials.split(/\\s+/)\n    if (parts.length !== 2) {\n      throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')\n    }\n\n    const jwtToken = parts[1]\n    const { authorized, payload } = await authenticate(jwtToken, config.jwt.secret)\n\n    if (!authorized || !payload || !payload.sub) {\n      throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')\n    }\n\n    if (requiredRights.length) {\n      const userRights = roleRights[payload.role as Role]\n      const hasRequiredRights = requiredRights.every((requiredRight) =>\n        (userRights as unknown as string[]).includes(requiredRight)\n      )\n      if (!hasRequiredRights && c.req.param('userId') !== payload.sub) {\n        throw new ApiError(httpStatus.FORBIDDEN, 'Forbidden')\n      }\n    }\n    if (!payload.isEmailVerified) {\n      const user = await getUserById(payload.sub, config['database'])\n      if (!user) {\n        throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')\n      }\n      const url = new URL(c.req.url)\n      if (url.pathname !== '/v1/auth/send-verification-email') {\n        throw new ApiError(httpStatus.FORBIDDEN, 'Please verify your email')\n      }\n    }\n    c.set('payload', payload)\n    await next()\n  }\n"
  },
  {
    "path": "src/middlewares/error.ts",
    "content": "import { getSentry } from '@hono/sentry'\nimport type { ErrorHandler } from 'hono'\nimport { StatusCode } from 'hono/utils/http-status'\nimport httpStatus from 'http-status'\nimport type { Toucan } from 'toucan-js'\nimport { ZodError } from 'zod'\nimport { Environment } from '../../bindings'\nimport { ApiError } from '../utils/api-error'\nimport { generateZodErrorMessage } from '../utils/zod'\n\nconst genericJSONErrMsg = 'Unexpected end of JSON input'\n\nexport const errorConverter = (err: unknown, sentry: Toucan): ApiError => {\n  let error = err\n  if (error instanceof ZodError) {\n    const errorMessage = generateZodErrorMessage(error)\n    error = new ApiError(httpStatus.BAD_REQUEST, errorMessage)\n  } else if (error instanceof SyntaxError && error.message.includes(genericJSONErrMsg)) {\n    throw new ApiError(httpStatus.BAD_REQUEST, 'Invalid JSON payload')\n  } else if (!(error instanceof ApiError)) {\n    const castedErr = (typeof error === 'object' ? error : {}) as Record<string, unknown>\n    const statusCode: StatusCode =\n      typeof castedErr.statusCode === 'number'\n        ? (castedErr.statusCode as StatusCode)\n        : httpStatus.INTERNAL_SERVER_ERROR\n    const message = (castedErr.description ||\n      castedErr.message ||\n      httpStatus[statusCode.toString() as keyof typeof httpStatus]) as string\n    if (statusCode >= httpStatus.INTERNAL_SERVER_ERROR) {\n      // Log any unhandled application error\n      sentry.captureException(error)\n    }\n    error = new ApiError(statusCode, message, false)\n  }\n  return error as ApiError\n}\n\nexport const errorHandler: ErrorHandler<Environment> = async (err, c) => {\n  // Can't load config in case error is inside config so load env here and default\n  // to highest obscurity aka production if env is not set\n  const env = c.env.ENV || 'production'\n  const sentry = getSentry(c)\n  const error = errorConverter(err, sentry)\n  if (env === 'production' && !error.isOperational) {\n    error.statusCode = httpStatus.INTERNAL_SERVER_ERROR\n    error.message = httpStatus[httpStatus.INTERNAL_SERVER_ERROR].toString()\n  }\n  const response = {\n    code: error.statusCode,\n    message: error.message,\n    ...(env === 'development' && { stack: err.stack })\n  }\n  delete c.error // Don't pass to sentry middleware as it is either logged or already handled\n  return c.json(response, error.statusCode as StatusCode)\n}\n"
  },
  {
    "path": "src/middlewares/rate-limiter.ts",
    "content": "import dayjs from 'dayjs'\nimport { Context, MiddlewareHandler } from 'hono'\nimport httpStatus from 'http-status'\nimport { Environment } from '../../bindings'\nimport { ApiError } from '../utils/api-error'\n\nconst fakeDomain = 'http://rate-limiter.com/'\n\nconst getRateLimitKey = (c: Context) => {\n  const ip = c.req.raw.headers.get('cf-connecting-ip')\n  const user = c.get('payload')?.sub\n  const uniqueKey = user ? user : ip\n  return uniqueKey\n}\n\nconst getCacheKey = (endpoint: string, key: number | string, limit: number, interval: number) => {\n  return `${fakeDomain}${endpoint}/${key}/${limit}/${interval}`\n}\n\nconst setRateLimitHeaders = (\n  c: Context,\n  secondsExpires: number,\n  limit: number,\n  remaining: number,\n  interval: number\n) => {\n  c.header('X-RateLimit-Limit', limit.toString())\n  c.header('X-RateLimit-Remaining', remaining.toString())\n  c.header('X-RateLimit-Reset', secondsExpires.toString())\n  c.header('X-RateLimit-Policy', `${limit};w=${interval};comment=\"Sliding window\"`)\n}\n\nexport const rateLimit = (interval: number, limit: number): MiddlewareHandler<Environment> => {\n  return async (c, next) => {\n    const key = getRateLimitKey(c)\n    const endpoint = new URL(c.req.url).pathname\n    const id = c.env.RATE_LIMITER.idFromName(key)\n    const rateLimiter = c.env.RATE_LIMITER.get(id)\n    const cache = await caches.open('rate-limiter')\n    const cacheKey = getCacheKey(endpoint, key, limit, interval)\n    const cached = await cache.match(cacheKey)\n    let res: Response\n    if (!cached) {\n      res = await rateLimiter.fetch(\n        new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify({\n            scope: endpoint,\n            key,\n            limit,\n            interval\n          })\n        })\n      )\n    } else {\n      res = cached\n    }\n    const clonedRes = res.clone()\n    // eslint-disable-next-line no-console\n    console.log() // This randomly fixes isolated storage errors\n    const body = await clonedRes.json<{ blocked: boolean; remaining: number; expires: string }>()\n    const secondsExpires = dayjs(body.expires).unix() - dayjs().unix()\n    setRateLimitHeaders(c, secondsExpires, limit, body.remaining, interval)\n    if (body.blocked) {\n      if (!cached) {\n        // Only cache blocked responses\n        c.executionCtx.waitUntil(cache.put(cacheKey, res))\n      }\n      throw new ApiError(httpStatus.TOO_MANY_REQUESTS, 'Too many requests')\n    }\n    await next()\n  }\n}\n"
  },
  {
    "path": "src/models/base.model.ts",
    "content": "export abstract class BaseModel {\n  abstract private_fields: string[]\n\n  toJSON() {\n    const properties = Object.getOwnPropertyNames(this)\n    const publicProperties = properties.filter((property) => {\n      return !this.private_fields.includes(property) && property !== 'private_fields'\n    })\n    const json = publicProperties.reduce((obj: Record<string, unknown>, key: string) => {\n      obj[key] = this[key as keyof typeof this]\n      return obj\n    }, {})\n    return json\n  }\n}\n"
  },
  {
    "path": "src/models/oauth/apple-user.model.ts",
    "content": "import { authProviders } from '../../config/authProviders'\nimport { AppleUserType } from '../../types/oauth.types'\nimport { OAuthUserModel } from './oauth-base.model'\n\nexport class AppleUser extends OAuthUserModel {\n  constructor(user: AppleUserType) {\n    if (!user.email) {\n      throw new Error('Apple account must have an email linked')\n    }\n    super({\n      _id: user.sub,\n      providerType: authProviders.APPLE,\n      _name: user.name,\n      _email: user.email\n    })\n  }\n}\n"
  },
  {
    "path": "src/models/oauth/discord-user.model.ts",
    "content": "import { authProviders } from '../../config/authProviders'\nimport { DiscordUserType } from '../../types/oauth.types'\nimport { OAuthUserModel } from './oauth-base.model'\n\nexport class DiscordUser extends OAuthUserModel {\n  constructor(user: DiscordUserType) {\n    super({\n      providerType: authProviders.DISCORD,\n      _name: user.username,\n      _id: user.id,\n      _email: user.email\n    })\n  }\n}\n"
  },
  {
    "path": "src/models/oauth/facebook-user.model.ts",
    "content": "import { authProviders } from '../../config/authProviders'\nimport { FacebookUserType } from '../../types/oauth.types'\nimport { OAuthUserModel } from './oauth-base.model'\n\nexport class FacebookUser extends OAuthUserModel {\n  constructor(user: FacebookUserType) {\n    super({\n      providerType: authProviders.FACEBOOK,\n      _name: `${user.first_name} ${user.last_name}`,\n      _id: user.id,\n      _email: user.email\n    })\n  }\n}\n"
  },
  {
    "path": "src/models/oauth/github-user.model.ts",
    "content": "import { authProviders } from '../../config/authProviders'\nimport { GithubUserType } from '../../types/oauth.types'\nimport { OAuthUserModel } from './oauth-base.model'\n\nexport class GithubUser extends OAuthUserModel {\n  constructor(user: GithubUserType) {\n    super({\n      _id: user.id.toString(),\n      providerType: authProviders.GITHUB,\n      _name: user.name,\n      _email: user.email\n    })\n  }\n}\n"
  },
  {
    "path": "src/models/oauth/google-user.model.ts",
    "content": "import { authProviders } from '../../config/authProviders'\nimport { GoogleUserType } from '../../types/oauth.types'\nimport { OAuthUserModel } from './oauth-base.model'\n\nexport class GoogleUser extends OAuthUserModel {\n  constructor(user: GoogleUserType) {\n    super({\n      providerType: authProviders.GOOGLE,\n      _name: user.name,\n      _id: user.id,\n      _email: user.email\n    })\n  }\n}\n"
  },
  {
    "path": "src/models/oauth/oauth-base.model.ts",
    "content": "import { AuthProviderType, OAuthUserType } from '../../types/oauth.types'\nimport { BaseModel } from '../base.model'\n\nexport class OAuthUserModel extends BaseModel implements OAuthUserType {\n  _id: string\n  _email: string\n  _name?: string\n  providerType: AuthProviderType\n\n  private_fields = []\n\n  constructor(user: OAuthUserType) {\n    super()\n    this._id = `${user._id}`\n    this._email = user._email\n    this._name = user._name || undefined\n    this.providerType = user.providerType\n  }\n}\n"
  },
  {
    "path": "src/models/oauth/spotify-user.model.ts",
    "content": "import { authProviders } from '../../config/authProviders'\nimport { SpotifyUserType } from '../../types/oauth.types'\nimport { OAuthUserModel } from './oauth-base.model'\n\nexport class SpotifyUser extends OAuthUserModel {\n  constructor(user: SpotifyUserType) {\n    super({\n      providerType: authProviders.SPOTIFY,\n      _name: user.display_name,\n      _id: user.id,\n      _email: user.email\n    })\n  }\n}\n"
  },
  {
    "path": "src/models/one-time-oauth-code.ts",
    "content": "import { Selectable } from 'kysely'\nimport { OneTimeOauthCodeTable } from '../tables/one-time-oauth-code.table'\nimport { BaseModel } from './base.model'\n\nexport class OneTimeOauthCode extends BaseModel implements Selectable<OneTimeOauthCodeTable> {\n  code: string\n  user_id: string\n  access_token: string\n  access_token_expires_at: Date\n  refresh_token: string\n  refresh_token_expires_at: Date\n  expires_at: Date\n  created_at: Date\n  updated_at: Date\n\n  private_fields = ['created_at', 'updated_at']\n\n  constructor(oneTimeCode: Selectable<OneTimeOauthCodeTable>) {\n    super()\n    this.code = oneTimeCode.code\n    this.user_id = oneTimeCode.user_id\n    this.access_token = oneTimeCode.access_token\n    this.access_token_expires_at = oneTimeCode.access_token_expires_at\n    this.refresh_token = oneTimeCode.refresh_token\n    this.refresh_token_expires_at = oneTimeCode.refresh_token_expires_at\n    this.expires_at = oneTimeCode.expires_at\n    this.created_at = oneTimeCode.created_at\n    this.updated_at = oneTimeCode.updated_at\n  }\n}\n"
  },
  {
    "path": "src/models/token.model.ts",
    "content": "export interface TokenResponse {\n  access: {\n    token: string\n    expires: Date\n  }\n  refresh: {\n    token: string\n    expires: Date\n  }\n}\n"
  },
  {
    "path": "src/models/user.model.ts",
    "content": "import bcrypt from 'bcryptjs'\nimport { Selectable } from 'kysely'\nimport { Role } from '../config/roles'\nimport { UserTable } from '../tables/user.table'\nimport { BaseModel } from './base.model'\n\nexport class User extends BaseModel implements Selectable<UserTable> {\n  id: string\n  name: string | null\n  email: string\n  is_email_verified: boolean\n  role: Role\n  password: string | null\n\n  private_fields = ['password', 'created_at', 'updated_at']\n\n  constructor(user: Selectable<UserTable>) {\n    super()\n    this.role = user.role\n    this.id = user.id\n    this.name = user.name || null\n    this.email = user.email\n    this.is_email_verified = user.is_email_verified\n    this.role = user.role\n    this.password = user.password\n  }\n\n  isPasswordMatch = async (userPassword: string): Promise<boolean> => {\n    if (!this.password) throw 'No password connected to user'\n    return await bcrypt.compare(userPassword, this.password)\n  }\n}\n"
  },
  {
    "path": "src/routes/auth.route.ts",
    "content": "import { Hono } from 'hono'\nimport { Environment } from '../../bindings'\nimport * as authController from '../controllers/auth/auth.controller'\nimport * as appleController from '../controllers/auth/oauth/apple.controller'\nimport * as discordController from '../controllers/auth/oauth/discord.controller'\nimport * as facebookController from '../controllers/auth/oauth/facebook.controller'\nimport * as githubController from '../controllers/auth/oauth/github.controller'\nimport * as googleController from '../controllers/auth/oauth/google.controller'\nimport * as oauthController from '../controllers/auth/oauth/oauth.controller'\nimport * as spotifyController from '../controllers/auth/oauth/spotify.controller'\nimport { auth } from '../middlewares/auth'\nimport { rateLimit } from '../middlewares/rate-limiter'\n\nexport const route = new Hono<Environment>()\n\nconst twoMinutes = 120\nconst oneRequest = 1\n\nroute.post('/register', authController.register)\nroute.post('/login', authController.login)\nroute.post('/refresh-tokens', authController.refreshTokens)\nroute.post('/forgot-password', authController.forgotPassword)\nroute.post('/reset-password', authController.resetPassword)\nroute.post(\n  '/send-verification-email',\n  auth(),\n  rateLimit(twoMinutes, oneRequest),\n  authController.sendVerificationEmail\n)\nroute.post('/verify-email', authController.verifyEmail)\nroute.get('/authorisations', auth(), authController.getAuthorisations)\n\nroute.get('/github/redirect', githubController.githubRedirect)\nroute.get('/google/redirect', googleController.googleRedirect)\nroute.get('/spotify/redirect', spotifyController.spotifyRedirect)\nroute.get('/discord/redirect', discordController.discordRedirect)\nroute.get('/facebook/redirect', facebookController.facebookRedirect)\nroute.get('/apple/redirect', appleController.appleRedirect)\n\nroute.post('/github/callback', githubController.githubCallback)\nroute.post('/spotify/callback', spotifyController.spotifyCallback)\nroute.post('/discord/callback', discordController.discordCallback)\nroute.post('/google/callback', googleController.googleCallback)\nroute.post('/facebook/callback', facebookController.facebookCallback)\nroute.post('/apple/callback', appleController.appleCallback)\n\nroute.post('/github/:userId', auth('manageUsers'), githubController.linkGithub)\nroute.post('/spotify/:userId', auth('manageUsers'), spotifyController.linkSpotify)\nroute.post('/discord/:userId', auth('manageUsers'), discordController.linkDiscord)\nroute.post('/google/:userId', auth('manageUsers'), googleController.linkGoogle)\nroute.post('/facebook/:userId', auth('manageUsers'), facebookController.linkFacebook)\nroute.post('/apple/:userId', auth('manageUsers'), appleController.linkApple)\n\nroute.delete('/github/:userId', auth('manageUsers'), githubController.deleteGithubLink)\nroute.delete('/spotify/:userId', auth('manageUsers'), spotifyController.deleteSpotifyLink)\nroute.delete('/discord/:userId', auth('manageUsers'), discordController.deleteDiscordLink)\nroute.delete('/google/:userId', auth('manageUsers'), googleController.deleteGoogleLink)\nroute.delete('/facebook/:userId', auth('manageUsers'), facebookController.deleteFacebookLink)\nroute.delete('/apple/:userId', auth('manageUsers'), appleController.deleteAppleLink)\n\nroute.post('/validate', oauthController.validateOauthOneTimeCode)\n"
  },
  {
    "path": "src/routes/index.ts",
    "content": "import { route as authRoute } from './auth.route'\nimport { route as userRoute } from './user.route'\n\nconst base_path = 'v1'\n\nexport const defaultRoutes = [\n  {\n    path: `/${base_path}/auth`,\n    route: authRoute\n  },\n  {\n    path: `/${base_path}/users`,\n    route: userRoute\n  }\n]\n"
  },
  {
    "path": "src/routes/user.route.ts",
    "content": "import { Hono } from 'hono'\nimport { Environment } from '../../bindings'\nimport * as userController from '../controllers/user.controller'\nimport { auth } from '../middlewares/auth'\n\nexport const route = new Hono<Environment>()\n\nroute.post('/', auth('manageUsers'), userController.createUser)\nroute.get('/', auth('getUsers'), userController.getUsers)\n\nroute.get('/:userId', auth('getUsers'), userController.getUser)\nroute.patch('/:userId', auth('manageUsers'), userController.updateUser)\nroute.delete('/:userId', auth('manageUsers'), userController.deleteUser)\n"
  },
  {
    "path": "src/services/auth.service.ts",
    "content": "import httpStatus from 'http-status'\nimport { Config } from '../config/config'\nimport { getDBClient } from '../config/database'\nimport { Role } from '../config/roles'\nimport { tokenTypes } from '../config/tokens'\nimport { OAuthUserModel } from '../models/oauth/oauth-base.model'\nimport { TokenResponse } from '../models/token.model'\nimport { User } from '../models/user.model'\nimport { AuthProviderType } from '../types/oauth.types'\nimport { ApiError } from '../utils/api-error'\nimport { Register } from '../validations/auth.validation'\nimport * as tokenService from './token.service'\nimport * as userService from './user.service'\nimport { createUser } from './user.service'\n\nexport const loginUserWithEmailAndPassword = async (\n  email: string,\n  password: string,\n  databaseConfig: Config['database']\n): Promise<User> => {\n  const user = await userService.getUserByEmail(email, databaseConfig)\n  // If password is null then the user must login with a social account\n  if (user && !user.password) {\n    throw new ApiError(httpStatus.UNAUTHORIZED, 'Please login with your social account')\n  }\n  if (!user || !(await user.isPasswordMatch(password))) {\n    throw new ApiError(httpStatus.UNAUTHORIZED, 'Incorrect email or password')\n  }\n  return user\n}\n\nexport const refreshAuth = async (refreshToken: string, config: Config): Promise<TokenResponse> => {\n  try {\n    const refreshTokenDoc = await tokenService.verifyToken(\n      refreshToken,\n      tokenTypes.REFRESH,\n      config.jwt.secret\n    )\n    if (!refreshTokenDoc.sub) {\n      throw new Error()\n    }\n    const user = await userService.getUserById(refreshTokenDoc.sub, config.database)\n    if (!user) {\n      throw new Error()\n    }\n    return tokenService.generateAuthTokens(user, config.jwt)\n  } catch {\n    throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')\n  }\n}\n\nexport const register = async (\n  body: Register,\n  databaseConfig: Config['database']\n): Promise<User> => {\n  const registerBody = { ...body, role: 'user' as Role, is_email_verified: false }\n  const newUser = await createUser(registerBody, databaseConfig)\n  return newUser\n}\n\nexport const resetPassword = async (\n  resetPasswordToken: string,\n  newPassword: string,\n  config: Config\n): Promise<void> => {\n  try {\n    const resetPasswordTokenDoc = await tokenService.verifyToken(\n      resetPasswordToken,\n      tokenTypes.RESET_PASSWORD,\n      config.jwt.secret\n    )\n    if (!resetPasswordTokenDoc.sub) {\n      throw new Error()\n    }\n    const userId = resetPasswordTokenDoc.sub\n    const user = await userService.getUserById(userId, config.database)\n    if (!user) {\n      throw new Error()\n    }\n    await userService.updateUserById(user.id, { password: newPassword }, config.database)\n  } catch {\n    throw new ApiError(httpStatus.UNAUTHORIZED, 'Password reset failed')\n  }\n}\n\nexport const verifyEmail = async (verifyEmailToken: string, config: Config): Promise<void> => {\n  try {\n    const verifyEmailTokenDoc = await tokenService.verifyToken(\n      verifyEmailToken,\n      tokenTypes.VERIFY_EMAIL,\n      config.jwt.secret\n    )\n    if (!verifyEmailTokenDoc.sub) {\n      throw new Error()\n    }\n    const userId = verifyEmailTokenDoc.sub\n    const user = await userService.getUserById(userId, config.database)\n    if (!user) {\n      throw new Error()\n    }\n    await userService.updateUserById(user.id, { is_email_verified: true }, config.database)\n  } catch {\n    throw new ApiError(httpStatus.UNAUTHORIZED, 'Email verification failed')\n  }\n}\n\nexport const loginOrCreateUserWithOauth = async (\n  providerUser: OAuthUserModel,\n  databaseConfig: Config['database']\n): Promise<User> => {\n  const user = await userService.getUserByProviderIdType(\n    providerUser._id,\n    providerUser.providerType,\n    databaseConfig\n  )\n  if (user) return user\n  return userService.createOauthUser(providerUser, databaseConfig)\n}\n\nexport const linkUserWithOauth = async (\n  userId: string,\n  providerUser: OAuthUserModel,\n  databaseConfig: Config['database']\n): Promise<void> => {\n  const db = getDBClient(databaseConfig)\n  await db.transaction().execute(async (trx) => {\n    try {\n      await trx\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', userId)\n        .executeTakeFirstOrThrow()\n    } catch {\n      throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')\n    }\n    await trx\n      .insertInto('authorisations')\n      .values({\n        user_id: userId,\n        provider_user_id: providerUser._id,\n        provider_type: providerUser.providerType\n      })\n      .executeTakeFirstOrThrow()\n  })\n}\n\nexport const deleteOauthLink = async (\n  userId: string,\n  provider: AuthProviderType,\n  databaseConfig: Config['database']\n): Promise<void> => {\n  const db = getDBClient(databaseConfig)\n  await db.transaction().execute(async (trx) => {\n    const { count } = trx.fn\n    let loginsNo: number\n    try {\n      const logins = await trx\n        .selectFrom('user')\n        .select('password')\n        .select(count<number>('authorisations.provider_user_id').as('authorisations'))\n        .leftJoin('authorisations', 'authorisations.user_id', 'user.id')\n        .where('user.id', '=', userId)\n        .groupBy('user.password')\n        .executeTakeFirstOrThrow()\n      loginsNo = logins.password !== null ? logins.authorisations + 1 : logins.authorisations\n    } catch {\n      throw new ApiError(httpStatus.BAD_REQUEST, 'Account not linked')\n    }\n    const minLoginMethods = 1\n    if (loginsNo <= minLoginMethods) {\n      throw new ApiError(httpStatus.BAD_REQUEST, 'Cannot unlink last login method')\n    }\n    const result = await trx\n      .deleteFrom('authorisations')\n      .where('user_id', '=', userId)\n      .where('provider_type', '=', provider)\n      .executeTakeFirst()\n    if (!result.numDeletedRows || Number(result.numDeletedRows) < 1) {\n      throw new ApiError(httpStatus.BAD_REQUEST, 'Account not linked')\n    }\n  })\n}\n"
  },
  {
    "path": "src/services/email.service.ts",
    "content": "import { SESClient, SendEmailCommand, Message } from '@aws-sdk/client-ses'\nimport { Config } from '../config/config'\n\nlet client: SESClient\n\nexport interface EmailData {\n  name: string\n  token: string\n}\n\nconst getClient = (awsConfig: Config['aws']): SESClient => {\n  client =\n    client ||\n    new SESClient({\n      credentials: {\n        accessKeyId: awsConfig.accessKeyId,\n        secretAccessKey: awsConfig.secretAccessKey\n      },\n      region: awsConfig.region\n    })\n  return client\n}\n\nconst sendEmail = async (\n  to: string,\n  sender: string,\n  message: Message,\n  awsConfig: Config['aws']\n): Promise<void> => {\n  const sesClient = getClient(awsConfig)\n  const command = new SendEmailCommand({\n    Destination: { ToAddresses: [to] },\n    Source: sender,\n    Message: message\n  })\n  await sesClient.send(command)\n}\n\nexport const sendResetPasswordEmail = async (\n  email: string,\n  emailData: EmailData,\n  config: Config\n): Promise<void> => {\n  const message = {\n    Subject: {\n      Data: 'Reset your password',\n      Charset: 'UTF-8'\n    },\n    Body: {\n      Text: {\n        Charset: 'UTF-8',\n        Data: `\n          Hello ${emailData.name}\n          Please reset your password by clicking the following link:\n          ${emailData.token}\n        `\n      }\n    }\n  }\n  await sendEmail(email, config.email.sender, message, config.aws)\n}\n\nexport const sendVerificationEmail = async (\n  email: string,\n  emailData: EmailData,\n  config: Config\n): Promise<void> => {\n  const message = {\n    Subject: {\n      Data: 'Verify your email address',\n      Charset: 'UTF-8'\n    },\n    Body: {\n      Text: {\n        Charset: 'UTF-8',\n        Data: `\n          Hello ${emailData.name}\n          Please verify your email by clicking the following link:\n          ${emailData.token}\n        `\n      }\n    }\n  }\n  await sendEmail(email, config.email.sender, message, config.aws)\n}\n"
  },
  {
    "path": "src/services/oauth/apple.service.ts",
    "content": "import httpStatus from 'http-status'\nimport { ApiError } from '../../utils/api-error'\n\ntype AppleResponse = {\n  error?: string\n  id_token?: string\n}\n\nexport const getIdTokenFromCode = async (\n  code: string,\n  clientId: string,\n  clientSecret: string,\n  redirectUrl: string\n) => {\n  const params = {\n    grant_type: 'authorization_code',\n    code,\n    client_id: clientId,\n    client_secret: clientSecret,\n    redirect_uri: redirectUrl,\n    response_mode: 'form_post'\n  }\n  const response = await fetch('https://appleid.apple.com/auth/token', {\n    method: 'POST',\n    headers: { 'content-type': 'application/x-www-form-urlencoded' },\n    body: new URLSearchParams(params).toString()\n  })\n  const result = (await response.json()) as AppleResponse\n  if (result.error || !result.id_token) {\n    throw new ApiError(httpStatus.UNAUTHORIZED, 'Unauthorized')\n  }\n  return result.id_token\n}\n"
  },
  {
    "path": "src/services/oauth/facebook.service.ts",
    "content": "import httpStatus from 'http-status'\nimport * as queryString from 'query-string'\nimport { ApiError } from '../../utils/api-error'\n\ntype Options = {\n  clientId: string\n  redirectUrl: string\n  scope?: string\n  responseType?: string\n  authType?: string\n  display?: string\n  state?: string\n}\n\n// TODO: remove when worker-auth-providers library fixed\nexport const redirect = async (options: Options) => {\n  const {\n    clientId,\n    redirectUrl,\n    scope = 'email, user_friends',\n    responseType = 'code',\n    authType = 'rerequest',\n    display = 'popup',\n    state\n  } = options\n  if (!clientId) {\n    throw new ApiError(httpStatus.BAD_REQUEST, 'Bad request')\n  }\n  const params = queryString.stringify({\n    client_id: clientId,\n    redirect_uri: redirectUrl,\n    scope,\n    response_type: responseType,\n    auth_type: authType,\n    display,\n    state\n  })\n  const url = `https://www.facebook.com/v4.0/dialog/oauth?${params}`\n  return url\n}\n"
  },
  {
    "path": "src/services/oauth/github.service.ts",
    "content": "import httpStatus from 'http-status'\nimport * as queryString from 'query-string'\nimport { ApiError } from '../../utils/api-error'\n\nconst DEFAULT_SCOPE = ['read:user', 'user:email']\nconst DEFAULT_ALLOW_SIGNUP = true\n\ntype Options = {\n  clientId: string\n  redirectTo?: string\n  scope?: string[]\n  allowSignup?: boolean\n  state?: string\n}\n\ntype Params = {\n  client_id: string\n  redirect_uri?: string\n  scope: string\n  allow_signup: boolean\n  state?: string\n}\n\n// TODO: remove when worker-auth-providers library fixed\nexport const redirect = async (options: Options) => {\n  const {\n    clientId,\n    redirectTo,\n    scope = DEFAULT_SCOPE,\n    allowSignup = DEFAULT_ALLOW_SIGNUP,\n    state\n  } = options\n  if (!clientId) {\n    throw new ApiError(httpStatus.BAD_REQUEST, 'Bad request')\n  }\n  const params: Params = {\n    client_id: clientId,\n    scope: scope.join(' '),\n    allow_signup: allowSignup,\n    state\n  }\n  if (redirectTo) {\n    params.redirect_uri = redirectTo\n  }\n  const paramString = queryString.stringify(params)\n  const githubLoginUrl = `https://github.com/login/oauth/authorize?${paramString}`\n  return githubLoginUrl\n}\n"
  },
  {
    "path": "src/services/oauth/spotify.service.ts",
    "content": "import httpStatus from 'http-status'\nimport * as queryString from 'query-string'\nimport { ApiError } from '../../utils/api-error'\n\ntype Options = {\n  clientId: string\n  redirectUrl?: string\n  scope?: string\n  responseType?: string\n  showDialog?: boolean\n  state?: string\n}\n\n// TODO: remove when worker-auth-providers library fixed\nexport const redirect = async (options: Options) => {\n  const {\n    clientId,\n    redirectUrl,\n    scope = 'user-library-read playlist-modify-private',\n    responseType = 'code',\n    showDialog = false,\n    state\n  } = options\n  if (!clientId) {\n    throw new ApiError(httpStatus.BAD_REQUEST, 'Bad request')\n  }\n  const params = queryString.stringify({\n    client_id: clientId,\n    redirect_uri: redirectUrl,\n    response_type: responseType,\n    scope,\n    show_dialog: showDialog,\n    state\n  })\n  const url = `https://accounts.spotify.com/authorize?${params}`\n  return url\n}\n"
  },
  {
    "path": "src/services/token.service.ts",
    "content": "import jwt, { JwtPayload } from '@tsndr/cloudflare-worker-jwt'\nimport dayjs, { Dayjs } from 'dayjs'\nimport httpStatus from 'http-status'\nimport { Selectable } from 'kysely'\nimport { Config } from '../config/config'\nimport { getDBClient } from '../config/database'\nimport { Role } from '../config/roles'\nimport { TokenType, tokenTypes } from '../config/tokens'\nimport { OneTimeOauthCode } from '../models/one-time-oauth-code'\nimport { TokenResponse } from '../models/token.model'\nimport { User } from '../models/user.model'\nimport { ApiError } from '../utils/api-error'\nimport { generateId } from '../utils/utils'\n\nexport const generateToken = async (\n  userId: string,\n  type: TokenType,\n  role: Role,\n  expires: Dayjs,\n  secret: string,\n  isEmailVerified: boolean\n) => {\n  const payload = {\n    sub: userId.toString(),\n    exp: expires.unix(),\n    iat: dayjs().unix(),\n    type,\n    role,\n    isEmailVerified\n  }\n  return jwt.sign(payload, secret)\n}\n\nexport const generateAuthTokens = async (user: Selectable<User>, jwtConfig: Config['jwt']) => {\n  const accessTokenExpires = dayjs().add(jwtConfig.accessExpirationMinutes, 'minutes')\n  const accessToken = await generateToken(\n    user.id,\n    tokenTypes.ACCESS,\n    user.role,\n    accessTokenExpires,\n    jwtConfig.secret,\n    user.is_email_verified\n  )\n  const refreshTokenExpires = dayjs().add(jwtConfig.refreshExpirationDays, 'days')\n  const refreshToken = await generateToken(\n    user.id,\n    tokenTypes.REFRESH,\n    user.role,\n    refreshTokenExpires,\n    jwtConfig.secret,\n    user.is_email_verified\n  )\n  return {\n    access: {\n      token: accessToken,\n      expires: accessTokenExpires.toDate()\n    },\n    refresh: {\n      token: refreshToken,\n      expires: refreshTokenExpires.toDate()\n    }\n  }\n}\n\nexport const verifyToken = async (token: string, type: TokenType, secret: string) => {\n  const isValid = await jwt.verify(token, secret)\n  if (!isValid) {\n    throw new Error('Token not valid')\n  }\n  const decoded = jwt.decode(token)\n  const payload = decoded.payload as JwtPayload\n  if (type !== payload.type) {\n    throw new Error('Token not valid')\n  }\n  return payload\n}\n\nexport const generateVerifyEmailToken = async (\n  user: Selectable<User>,\n  jwtConfig: Config['jwt']\n) => {\n  const expires = dayjs().add(jwtConfig.verifyEmailExpirationMinutes, 'minutes')\n  const verifyEmailToken = await generateToken(\n    user.id,\n    tokenTypes.VERIFY_EMAIL,\n    user.role,\n    expires,\n    jwtConfig.secret,\n    user.is_email_verified\n  )\n  return verifyEmailToken\n}\n\nexport const generateResetPasswordToken = async (\n  user: Selectable<User>,\n  jwtConfig: Config['jwt']\n) => {\n  const expires = dayjs().add(jwtConfig.resetPasswordExpirationMinutes, 'minutes')\n  const resetPasswordToken = await generateToken(\n    user.id,\n    tokenTypes.RESET_PASSWORD,\n    user.role,\n    expires,\n    jwtConfig.secret,\n    user.is_email_verified\n  )\n  return resetPasswordToken\n}\n\nconst generateOneTimeOauthCodeToken = () => {\n  return generateId()\n}\n\nexport const createOneTimeOauthCode = async (\n  userId: string,\n  tokens: TokenResponse,\n  config: Config\n) => {\n  const db = getDBClient(config.database)\n  let attempts = 0\n  const maxAttempts = 5\n  let code = generateOneTimeOauthCodeToken()\n  while (attempts < maxAttempts) {\n    try {\n      await db\n        .insertInto('one_time_oauth_code')\n        .values({\n          code,\n          user_id: userId,\n          access_token: tokens.access.token,\n          access_token_expires_at: tokens.access.expires,\n          refresh_token: tokens.refresh.token,\n          refresh_token_expires_at: tokens.refresh.expires,\n          expires_at: dayjs().add(config.jwt.accessExpirationMinutes, 'minutes').toDate()\n        })\n        .executeTakeFirstOrThrow()\n      break\n    } catch {\n      if (attempts >= maxAttempts) {\n        throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, 'Failed to create one time code')\n      }\n      code = generateOneTimeOauthCodeToken()\n      attempts++\n    }\n  }\n  return code\n}\n\nexport const getOneTimeOauthCode = async (code: string, config: Config) => {\n  const db = getDBClient(config.database)\n  const oneTimeCode = await db.transaction().execute(async (trx) => {\n    const oneTimeCode = await db\n      .selectFrom('one_time_oauth_code')\n      .selectAll()\n      .where('code', '=', code)\n      .where('expires_at', '>', dayjs().toDate())\n      .executeTakeFirst()\n    if (!oneTimeCode) {\n      throw new ApiError(httpStatus.BAD_REQUEST, 'Code invalid or expired')\n    }\n    await trx.deleteFrom('one_time_oauth_code').where('code', '=', code).execute()\n    return oneTimeCode\n  })\n  return new OneTimeOauthCode(oneTimeCode)\n}\n"
  },
  {
    "path": "src/services/user.service.ts",
    "content": "import httpStatus from 'http-status'\nimport { UpdateResult } from 'kysely'\nimport { Config } from '../config/config'\nimport { getDBClient } from '../config/database'\nimport { OAuthUserModel } from '../models/oauth/oauth-base.model'\nimport { User } from '../models/user.model'\nimport { UserTable } from '../tables/user.table'\nimport { AuthProviderType } from '../types/oauth.types'\nimport { ApiError } from '../utils/api-error'\nimport { generateId } from '../utils/utils'\nimport { CreateUser, UpdateUser } from '../validations/user.validation'\n\ninterface getUsersFilter {\n  email: string | undefined\n}\n\ninterface getUsersOptions {\n  sortBy: string\n  limit: number\n  page: number\n}\n\nexport const createUser = async (\n  userBody: CreateUser,\n  databaseConfig: Config['database']\n): Promise<User> => {\n  const db = getDBClient(databaseConfig)\n  const id = generateId()\n  try {\n    await db\n      .insertInto('user')\n      .values({ ...userBody, id })\n      .executeTakeFirstOrThrow()\n  } catch {\n    throw new ApiError(httpStatus.BAD_REQUEST, 'User already exists')\n  }\n  const user = (await getUserById(id, databaseConfig)) as User\n  return user\n}\n\nexport const createOauthUser = async (\n  providerUser: OAuthUserModel,\n  databaseConfig: Config['database']\n): Promise<User> => {\n  const db = getDBClient(databaseConfig)\n  try {\n    const id = generateId()\n    await db.transaction().execute(async (trx) => {\n      await trx\n        .insertInto('user')\n        .values({\n          id,\n          name: providerUser._name,\n          email: providerUser._email,\n          is_email_verified: true,\n          password: null,\n          role: 'user'\n        })\n        .executeTakeFirstOrThrow()\n      await trx\n        .insertInto('authorisations')\n        .values({\n          user_id: id,\n          provider_type: providerUser.providerType,\n          provider_user_id: providerUser._id\n        })\n        .executeTakeFirstOrThrow()\n    })\n  } catch {\n    throw new ApiError(\n      httpStatus.FORBIDDEN,\n      `Cannot signup with ${providerUser.providerType}, user already exists with that email`\n    )\n  }\n  const user = (await getUserByProviderIdType(\n    providerUser._id,\n    providerUser.providerType,\n    databaseConfig\n  )) as User\n  return new User(user)\n}\n\nexport const queryUsers = async (\n  filter: getUsersFilter,\n  options: getUsersOptions,\n  databaseConfig: Config['database']\n): Promise<User[]> => {\n  const db = getDBClient(databaseConfig)\n  const [sortField, direction] = options.sortBy.split(':') as [keyof UserTable, 'asc' | 'desc']\n  let usersQuery = db\n    .selectFrom('user')\n    .selectAll()\n    .orderBy(`user.${sortField}`, direction)\n    .limit(options.limit)\n    .offset(options.limit * options.page)\n  if (filter.email) {\n    usersQuery = usersQuery.where('user.email', '=', filter.email)\n  }\n  const users = await usersQuery.execute()\n  return users.map((user) => new User(user))\n}\n\nexport const getUserById = async (\n  id: string,\n  databaseConfig: Config['database']\n): Promise<User | undefined> => {\n  const db = getDBClient(databaseConfig)\n  const user = await db.selectFrom('user').selectAll().where('user.id', '=', id).executeTakeFirst()\n  return user ? new User(user) : undefined\n}\n\nexport const getUserByEmail = async (\n  email: string,\n  databaseConfig: Config['database']\n): Promise<User | undefined> => {\n  const db = getDBClient(databaseConfig)\n  const user = await db\n    .selectFrom('user')\n    .selectAll()\n    .where('user.email', '=', email)\n    .executeTakeFirst()\n  return user ? new User(user) : undefined\n}\n\nexport const getUserByProviderIdType = async (\n  id: string,\n  type: AuthProviderType,\n  databaseConfig: Config['database']\n): Promise<User | undefined> => {\n  const db = getDBClient(databaseConfig)\n  const user = await db\n    .selectFrom('user')\n    .innerJoin('authorisations', 'authorisations.user_id', 'user.id')\n    .selectAll()\n    .where('authorisations.provider_user_id', '=', id)\n    .where('authorisations.provider_type', '=', type)\n    .executeTakeFirst()\n  return user ? new User(user) : undefined\n}\n\nexport const updateUserById = async (\n  userId: string,\n  updateBody: Partial<UpdateUser>,\n  databaseConfig: Config['database']\n): Promise<User> => {\n  const db = getDBClient(databaseConfig)\n  let result: UpdateResult\n  try {\n    result = await db\n      .updateTable('user')\n      .set(updateBody)\n      .where('id', '=', userId)\n      .executeTakeFirst()\n  } catch {\n    throw new ApiError(httpStatus.BAD_REQUEST, 'User already exists')\n  }\n  if (!result.numUpdatedRows || Number(result.numUpdatedRows) < 1) {\n    throw new ApiError(httpStatus.NOT_FOUND, 'User not found')\n  }\n  const user = (await getUserById(userId, databaseConfig)) as User\n  return user\n}\n\nexport const deleteUserById = async (\n  userId: string,\n  databaseConfig: Config['database']\n): Promise<void> => {\n  const db = getDBClient(databaseConfig)\n  const result = await db.deleteFrom('user').where('user.id', '=', userId).executeTakeFirst()\n\n  if (!result.numDeletedRows || Number(result.numDeletedRows) < 1) {\n    throw new ApiError(httpStatus.NOT_FOUND, 'User not found')\n  }\n}\n\nexport const getAuthorisations = async (userId: string, databaseConfig: Config['database']) => {\n  const db = getDBClient(databaseConfig)\n  const auths = await db\n    .selectFrom('user')\n    .leftJoin('authorisations', 'authorisations.user_id', 'user.id')\n    .selectAll()\n    .where('user.id', '=', userId)\n    .execute()\n\n  if (!auths) {\n    throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')\n  }\n  const response = {\n    local: auths[0].password !== null ? true : false,\n    google: false,\n    facebook: false,\n    discord: false,\n    spotify: false,\n    github: false,\n    apple: false\n  }\n  for (const auth of auths) {\n    if (auth.provider_type === null) {\n      continue\n    }\n    response[auth.provider_type as AuthProviderType] = true\n  }\n  return response\n}\n"
  },
  {
    "path": "src/tables/oauth.table.ts",
    "content": "export interface AuthProviderTable {\n  provider_user_id: string\n  provider_type: string\n  user_id: string\n}\n"
  },
  {
    "path": "src/tables/one-time-oauth-code.table.ts",
    "content": "import { Generated } from 'kysely'\n\nexport interface OneTimeOauthCodeTable {\n  code: string\n  user_id: string\n  access_token: string\n  access_token_expires_at: Date\n  refresh_token: string\n  refresh_token_expires_at: Date\n  expires_at: Date\n  created_at: Generated<Date>\n  updated_at: Generated<Date>\n}\n"
  },
  {
    "path": "src/tables/user.table.ts",
    "content": "import { Role } from '../config/roles'\n\nexport interface UserTable {\n  id: string\n  name: string | null // null if not available on oauth account linking\n  email: string\n  password: string | null // null if user is created via OAuth\n  is_email_verified: boolean\n  role: Role\n}\n"
  },
  {
    "path": "src/types/oauth.types.ts",
    "content": "import { authProviders } from '../config/authProviders'\nimport { AppleUser } from '../models/oauth/apple-user.model'\nimport { DiscordUser } from '../models/oauth/discord-user.model'\nimport { FacebookUser } from '../models/oauth/facebook-user.model'\nimport { GithubUser } from '../models/oauth/github-user.model'\nimport { GoogleUser } from '../models/oauth/google-user.model'\nimport { SpotifyUser } from '../models/oauth/spotify-user.model'\n\nexport type AuthProviderType = (typeof authProviders)[keyof typeof authProviders]\n\nexport interface OAuthUserType {\n  _id: string\n  _email: string\n  _name?: string\n  providerType: AuthProviderType\n}\n\nexport interface AppleUserType {\n  sub: string\n  email?: string\n  name?: string\n}\n\nexport interface DiscordUserType {\n  id: string\n  email: string\n  username: string\n}\n\nexport interface FacebookUserType {\n  id: string\n  email: string\n  first_name: string\n  last_name: string\n}\n\nexport interface GithubUserType {\n  id: number\n  email: string\n  name: string\n}\n\nexport interface GoogleUserType {\n  id: string\n  email: string\n  name: string\n}\n\nexport interface SpotifyUserType {\n  id: string\n  email: string\n  display_name: string\n}\n\nexport interface OauthUserTypes {\n  facebook: FacebookUserType\n  discord: DiscordUserType\n  google: GoogleUserType\n  spotify: SpotifyUserType\n  apple: AppleUserType\n  github: GithubUserType\n}\n\nexport type ProviderUserMapping = {\n  [key in AuthProviderType]: new (\n    user: OauthUserTypes[key]\n  ) => FacebookUser | DiscordUser | GoogleUser | SpotifyUser | AppleUser | GithubUser\n}\n"
  },
  {
    "path": "src/utils/api-error.ts",
    "content": "export class ApiError extends Error {\n  statusCode: number\n  isOperational: boolean\n\n  constructor(statusCode: number, message: string, isOperational = true) {\n    super(message)\n    this.statusCode = statusCode\n    this.isOperational = isOperational\n  }\n}\n"
  },
  {
    "path": "src/utils/utils.ts",
    "content": "import { nanoid } from 'nanoid'\n\nexport const generateId = () => {\n  return nanoid()\n}\n"
  },
  {
    "path": "src/utils/zod.ts",
    "content": "import { ZodError } from 'zod'\nimport { fromError } from 'zod-validation-error'\n\nexport const generateZodErrorMessage = (error: ZodError): string => {\n  return fromError(error).message\n}\n"
  },
  {
    "path": "src/validations/auth.validation.ts",
    "content": "import { z } from 'zod'\nimport { password } from './custom.refine.validation'\nimport { hashPassword } from './custom.transform.validation'\n\nexport const register = z.strictObject({\n  email: z.string().email(),\n  password: z.string().superRefine(password).transform(hashPassword),\n  name: z.string()\n})\n\nexport type Register = z.infer<typeof register>\n\nexport const login = z.strictObject({\n  email: z.string(),\n  password: z.string()\n})\n\nexport const refreshTokens = z.strictObject({\n  refresh_token: z.string()\n})\n\nexport const forgotPassword = z.strictObject({\n  email: z.string().email()\n})\n\nexport const resetPassword = z.strictObject({\n  query: z.object({\n    token: z.string()\n  }),\n  body: z.object({\n    password: z.string().superRefine(password).transform(hashPassword)\n  })\n})\n\nexport const verifyEmail = z.strictObject({\n  token: z.string()\n})\n\nexport const changePassword = z.strictObject({\n  oldPassword: z.string().superRefine(password).transform(hashPassword),\n  newPassword: z.string().superRefine(password).transform(hashPassword)\n})\n\nexport const oauthCallback = z.object({\n  code: z.string(),\n  platform: z.union([z.literal('web'), z.literal('ios'), z.literal('android')])\n})\n\nexport const linkApple = z.object({\n  code: z.string()\n})\n\nexport const oauthRedirect = z.object({\n  state: z.string()\n})\n\nexport const validateOneTimeCode = z.object({\n  code: z.string()\n})\n\nexport const stateValidation = z.object({\n  platform: z.union([z.literal('web'), z.literal('ios'), z.literal('android')])\n})\n"
  },
  {
    "path": "src/validations/custom.refine.validation.ts",
    "content": "import { z } from 'zod'\n\nexport const password = async (value: string, ctx: z.RefinementCtx): Promise<void> => {\n  if (value.length < 8) {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      message: 'password must be at least 8 characters'\n    })\n    return\n  }\n  if (!value.match(/\\d/) || !value.match(/[a-zA-Z]/)) {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      message: 'password must contain at least 1 letter and 1 number'\n    })\n    return\n  }\n}\n"
  },
  {
    "path": "src/validations/custom.transform.validation.ts",
    "content": "import bcrypt from 'bcryptjs'\n\nexport const hashPassword = async (value: string): Promise<string> => {\n  const hashedPassword = await bcrypt.hash(value, 8)\n  return hashedPassword\n}\n"
  },
  {
    "path": "src/validations/custom.type.validation.ts",
    "content": "import { z } from 'zod'\n\nexport const roleZodType = z.union([z.literal('admin'), z.literal('user')])\n"
  },
  {
    "path": "src/validations/user.validation.ts",
    "content": "import { z } from 'zod'\nimport { password } from './custom.refine.validation'\nimport { hashPassword } from './custom.transform.validation'\nimport { roleZodType } from './custom.type.validation'\n\nexport const createUser = z.strictObject({\n  email: z.string().email(),\n  password: z.string().superRefine(password).transform(hashPassword),\n  name: z.string(),\n  is_email_verified: z\n    .any()\n    .optional()\n    .transform(() => false),\n  role: roleZodType\n})\n\nexport type CreateUser = z.infer<typeof createUser>\n\nexport const getUsers = z.object({\n  email: z.string().optional(),\n  sort_by: z.string().optional().default('id:asc'),\n  limit: z.coerce.number().optional().default(10),\n  page: z.coerce.number().optional().default(0)\n})\n\nexport const getUser = z.object({ userId: z.string() })\n\nexport const updateUser = z.strictObject({\n  params: z.object({ userId: z.string() }),\n  body: z\n    .object({\n      email: z.string().email().optional(),\n      name: z.string().optional(),\n      role: z.union([z.literal('admin'), z.literal('user')]).optional()\n    })\n    .refine(({ email, name, role }) => email || name || role, {\n      message: 'At least one field is required'\n    })\n})\n\nexport type UpdateUser =\n  | z.infer<typeof updateUser>['body']\n  | { password: string }\n  | { is_email_verified: boolean }\n\nexport const deleteUser = z.strictObject({ userId: z.string() })\n"
  },
  {
    "path": "tests/cloudflare-test.d.ts",
    "content": "declare module 'cloudflare:test' {\n  import { Environment } from '../bindings'\n  type ProvidedEnv = Environment\n}\n"
  },
  {
    "path": "tests/fixtures/authorisations.fixture.ts",
    "content": "import { faker } from '@faker-js/faker'\nimport { Insertable } from 'kysely'\nimport { authProviders } from '../../src/config/authProviders'\nimport { Config } from '../../src/config/config'\nimport { getDBClient } from '../../src/config/database'\nimport { AuthProviderTable } from '../../src/tables/oauth.table'\n\nexport const githubAuthorisation = (userId: string) => ({\n  provider_type: authProviders.GITHUB,\n  provider_user_id: faker.number.int().toString(),\n  user_id: userId\n})\n\nexport const discordAuthorisation = (userId: string) => ({\n  provider_type: authProviders.DISCORD,\n  provider_user_id: faker.number.int().toString(),\n  user_id: userId\n})\n\nexport const spotifyAuthorisation = (userId: string) => ({\n  provider_type: authProviders.SPOTIFY,\n  provider_user_id: faker.number.int().toString(),\n  user_id: userId\n})\n\nexport const googleAuthorisation = (userId: string) => ({\n  provider_type: authProviders.GOOGLE,\n  provider_user_id: faker.number.int().toString(),\n  user_id: userId\n})\n\nexport const facebookAuthorisation = (userId: string) => ({\n  provider_type: authProviders.FACEBOOK,\n  provider_user_id: faker.number.int().toString(),\n  user_id: userId\n})\n\nexport const appleAuthorisation = (userId: string) => ({\n  provider_type: authProviders.APPLE,\n  provider_user_id: faker.number.int().toString(),\n  user_id: userId\n})\n\nexport const insertAuthorisations = async (\n  authorisations: Insertable<AuthProviderTable>[],\n  databaseConfig: Config['database']\n) => {\n  const client = getDBClient(databaseConfig)\n  for await (const authorisation of authorisations) {\n    await client.insertInto('authorisations').values(authorisation).executeTakeFirst()\n  }\n}\n"
  },
  {
    "path": "tests/fixtures/token.fixture.ts",
    "content": "import dayjs from 'dayjs'\nimport { Config } from '../../src/config/config'\nimport { Role } from '../../src/config/roles'\nimport { tokenTypes, TokenType } from '../../src/config/tokens'\nimport * as tokenService from '../../src/services/token.service'\n\nexport interface TokenResponse {\n  access: {\n    token: string\n    expires: string\n  }\n  refresh: {\n    token: string\n    expires: string\n  }\n}\n\nexport const getAccessToken = async (\n  userId: string,\n  role: Role,\n  jwtConfig: Config['jwt'],\n  type: TokenType = tokenTypes.ACCESS,\n  isEmailVerified = true\n) => {\n  const expires = dayjs().add(jwtConfig.accessExpirationMinutes, 'minutes')\n  const token = await tokenService.generateToken(\n    userId,\n    type,\n    role,\n    expires,\n    jwtConfig.secret,\n    isEmailVerified\n  )\n  return token\n}\n"
  },
  {
    "path": "tests/fixtures/user.fixture.ts",
    "content": "import { faker } from '@faker-js/faker'\nimport bcrypt from 'bcryptjs'\nimport { Insertable } from 'kysely'\nimport { Config } from '../../src/config/config'\nimport { getDBClient } from '../../src/config/database'\nimport { UserTable } from '../../src/tables/user.table'\nimport { generateId } from '../../src/utils/utils'\n\nconst password = 'password1'\nconst salt = bcrypt.genSaltSync(8)\nconst hashedPassword = bcrypt.hashSync(password, salt)\n\nexport type MockUser = Insertable<UserTable>\n\nexport interface UserResponse {\n  id: string\n  name: string\n  email: string\n  role: string\n  is_email_verified: boolean\n}\n\nexport const userOne: MockUser = {\n  id: generateId(),\n  name: faker.person.fullName(),\n  email: faker.internet.email().toLowerCase(),\n  password,\n  role: 'user',\n  is_email_verified: false\n}\n\nexport const userTwo: MockUser = {\n  id: generateId(),\n  name: faker.person.fullName(),\n  email: faker.internet.email().toLowerCase(),\n  password,\n  role: 'user',\n  is_email_verified: false\n}\n\nexport const admin: MockUser = {\n  id: generateId(),\n  name: faker.person.fullName(),\n  email: faker.internet.email().toLowerCase(),\n  password,\n  role: 'admin',\n  is_email_verified: false\n}\n\nexport const insertUsers = async (users: MockUser[], databaseConfig: Config['database']) => {\n  const hashedUsers = users.map((user) => ({\n    ...user,\n    password: user.password ? hashedPassword : null\n  }))\n  const client = getDBClient(databaseConfig)\n  for await (const user of hashedUsers) {\n    await client.insertInto('user').values(user).executeTakeFirst()\n  }\n}\n"
  },
  {
    "path": "tests/integration/auth/auth.test.ts",
    "content": "// TODO: Add SES mock client back. It's not working with vitest\n// import { mockClient } from 'aws-sdk-client-mock'\nimport { SESClient, SendEmailCommand } from '@aws-sdk/client-ses'\nimport { faker } from '@faker-js/faker'\nimport bcrypt from 'bcryptjs'\nimport { env } from 'cloudflare:test'\nimport dayjs from 'dayjs'\nimport httpStatus from 'http-status'\nimport { describe, expect, test, beforeEach } from 'vitest'\nimport { getConfig } from '../../../src/config/config'\nimport { getDBClient } from '../../../src/config/database'\nimport { tokenTypes } from '../../../src/config/tokens'\nimport * as tokenService from '../../../src/services/token.service'\nimport { Register } from '../../../src/validations/auth.validation'\nimport {\n  appleAuthorisation,\n  discordAuthorisation,\n  facebookAuthorisation,\n  githubAuthorisation,\n  googleAuthorisation,\n  insertAuthorisations,\n  spotifyAuthorisation\n} from '../../fixtures/authorisations.fixture'\nimport { getAccessToken, TokenResponse } from '../../fixtures/token.fixture'\nimport { userOne, insertUsers, UserResponse, userTwo } from '../../fixtures/user.fixture'\nimport { expectExtension, mockClient } from '../../mocks/awsClientStub'\nimport { clearDBTables } from '../../utils/clear-db-tables'\nimport { request } from '../../utils/test-request'\n\nconst config = getConfig(env)\nconst client = getDBClient(config.database)\n\nexpect.extend(expectExtension)\nclearDBTables(['user', 'authorisations'], config.database)\n\ndescribe('Auth routes', () => {\n  describe('POST /v1/auth/register', () => {\n    let newUser: Register\n    beforeEach(() => {\n      newUser = {\n        name: faker.person.fullName(),\n        email: faker.internet.email().toLowerCase(),\n        password: 'password1'\n      }\n    })\n\n    test('should return 201 and successfully register user if request data is ok', async () => {\n      const res = await request('/v1/auth/register', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.CREATED)\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(body.user).not.toHaveProperty('password')\n      expect(body.user).toEqual({\n        id: expect.anything(),\n        name: newUser.name,\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 0\n      })\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', body.user.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).not.toBe(newUser.password)\n      expect(dbUser).toMatchObject({\n        name: newUser.name,\n        password: expect.anything(),\n        email: newUser.email,\n        is_email_verified: 0,\n        role: 'user'\n      })\n\n      expect(body.tokens).toEqual({\n        access: { token: expect.anything(), expires: expect.anything() },\n        refresh: { token: expect.anything(), expires: expect.anything() }\n      })\n    })\n\n    test('should return 400 error if email is invalid', async () => {\n      newUser.email = 'invalidEmail'\n\n      const res = await request('/v1/auth/register', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 error if email is already used', async () => {\n      await insertUsers([userOne], config.database)\n      newUser.email = userOne.email\n\n      const res = await request('/v1/auth/register', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 error if password length is less than 8 characters', async () => {\n      newUser.password = 'passwo1'\n\n      const res = await request('/v1/auth/register', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 error if role is set', async () => {\n      const res = await request('/v1/auth/register', {\n        method: 'POST',\n        body: JSON.stringify({ ...newUser, role: 'admin' }),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      const body = await res.json<{ code: number; message: string }>()\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n      expect(body.message).toContain(\"Validation error: Unrecognized key(s) in object: 'role'\")\n    })\n    test('should return 400 error if is_email_verified is set', async () => {\n      const res = await request('/v1/auth/register', {\n        method: 'POST',\n        body: JSON.stringify({ ...newUser, is_email_verified: true }),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      const body = await res.json<{ code: number; message: string }>()\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n      expect(body.message).toContain(\n        \"Validation error: Unrecognized key(s) in object: 'is_email_verified'\"\n      )\n    })\n    test('should return 400 if password does not contain both letters and numbers', async () => {\n      newUser.password = 'password'\n\n      const res = await request('/v1/auth/register', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n\n      newUser.password = '11111111'\n\n      const res2 = await request('/v1/auth/register', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res2.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n\n  describe('POST /v1/auth/login', () => {\n    test('should return 200 and login user if email and password match', async () => {\n      await insertUsers([userOne], config.database)\n      const loginCredentials = {\n        email: userOne.email,\n        password: userOne.password\n      }\n\n      const res = await request('/v1/auth/login', {\n        method: 'POST',\n        body: JSON.stringify(loginCredentials),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.OK)\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(body.user).not.toHaveProperty('password')\n      expect(body.user).toEqual({\n        id: expect.anything(),\n        name: userOne.name,\n        email: userOne.email,\n        role: userOne.role,\n        is_email_verified: 0\n      })\n\n      expect(body.tokens).toEqual({\n        access: { token: expect.anything(), expires: expect.anything() },\n        refresh: { token: expect.anything(), expires: expect.anything() }\n      })\n    })\n\n    test('should return 401 error if there are no users with that email', async () => {\n      const loginCredentials = {\n        email: userOne.email,\n        password: userOne.password\n      }\n\n      const res = await request('/v1/auth/login', {\n        method: 'POST',\n        body: JSON.stringify(loginCredentials),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n      const body = await res.json()\n      expect(body).toEqual({\n        code: httpStatus.UNAUTHORIZED,\n        message: 'Incorrect email or password'\n      })\n    })\n\n    test('should return 401 error if only oauth account exists', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const discordUser = discordAuthorisation(newUser.id)\n      await insertAuthorisations([discordUser], config.database)\n\n      const loginCredentials = {\n        email: newUser.email,\n        password: ''\n      }\n\n      const res = await request('/v1/auth/login', {\n        method: 'POST',\n        body: JSON.stringify(loginCredentials),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      const body = await res.json()\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n      expect(body).toEqual({\n        code: httpStatus.UNAUTHORIZED,\n        message: 'Please login with your social account'\n      })\n    })\n\n    test('should return 401 error if password is wrong', async () => {\n      await insertUsers([userOne], config.database)\n      const loginCredentials = {\n        email: userOne.email,\n        password: 'wrongPassword1'\n      }\n\n      const res = await request('/v1/auth/login', {\n        method: 'POST',\n        body: JSON.stringify(loginCredentials),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n      const body = await res.json()\n      expect(body).toEqual({\n        code: httpStatus.UNAUTHORIZED,\n        message: 'Incorrect email or password'\n      })\n    })\n  })\n\n  describe('POST /v1/auth/refresh-tokens', () => {\n    test('should return 200 and new auth tokens if refresh token is valid', async () => {\n      await insertUsers([userOne], config.database)\n      const expires = dayjs().add(config.jwt.refreshExpirationDays, 'days')\n      const refreshToken = await tokenService.generateToken(\n        userOne.id,\n        tokenTypes.REFRESH,\n        userOne.role,\n        expires,\n        config.jwt.secret,\n        userOne.is_email_verified\n      )\n\n      const res = await request('/v1/auth/refresh-tokens', {\n        method: 'POST',\n        body: JSON.stringify({ refresh_token: refreshToken }),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      const body = await res.json<{ tokens: TokenResponse }>()\n\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toEqual({\n        access: { token: expect.anything(), expires: expect.anything() },\n        refresh: { token: expect.anything(), expires: expect.anything() }\n      })\n    })\n\n    test('should return 400 error if refresh token is missing from request body', async () => {\n      const res = await request('/v1/auth/refresh-tokens', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 401 error if refresh token is signed using an invalid secret', async () => {\n      await insertUsers([userOne], config.database)\n      const expires = dayjs().add(config.jwt.refreshExpirationDays, 'days')\n      const refreshToken = await tokenService.generateToken(\n        userOne.id,\n        tokenTypes.REFRESH,\n        userOne.role,\n        expires,\n        'random secret',\n        userOne.is_email_verified\n      )\n\n      const res = await request('/v1/auth/refresh-tokens', {\n        method: 'POST',\n        body: JSON.stringify({ refresh_token: refreshToken }),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 401 error if refresh token is expired', async () => {\n      await insertUsers([userOne], config.database)\n      const expires = dayjs().subtract(1, 'minutes')\n      const refreshToken = await tokenService.generateToken(\n        userOne.id,\n        tokenTypes.REFRESH,\n        userOne.role,\n        expires,\n        config.jwt.secret,\n        userOne.is_email_verified\n      )\n\n      const res = await request('/v1/auth/refresh-tokens', {\n        method: 'POST',\n        body: JSON.stringify({ refresh_token: refreshToken }),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 401 error if user is not found', async () => {\n      const expires = dayjs().add(1, 'minutes')\n      const refreshToken = await tokenService.generateToken(\n        '123',\n        tokenTypes.REFRESH,\n        userOne.role,\n        expires,\n        config.jwt.secret,\n        userOne.is_email_verified\n      )\n\n      const res = await request('/v1/auth/refresh-tokens', {\n        method: 'POST',\n        body: JSON.stringify({ refresh_token: refreshToken }),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n  })\n\n  describe('POST /v1/auth/forgot-password', () => {\n    const sesMock = mockClient(SESClient)\n\n    beforeEach(() => {\n      sesMock.reset()\n    })\n\n    test('should return 204 and send reset password email to the user', async () => {\n      await insertUsers([userOne], config.database)\n      sesMock.on(SendEmailCommand).resolves({\n        MessageId: 'message-id'\n      })\n      const res = await request('/v1/auth/forgot-password', {\n        method: 'POST',\n        body: JSON.stringify({ email: userOne.email }),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n      expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 1)\n    })\n\n    test('should return 204 and send email if only has oauth account', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const discordUser = discordAuthorisation(newUser.id)\n      await insertAuthorisations([discordUser], config.database)\n\n      sesMock.on(SendEmailCommand).resolves({\n        MessageId: 'message-id'\n      })\n      const res = await request('/v1/auth/forgot-password', {\n        method: 'POST',\n        body: JSON.stringify({ email: newUser.email }),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n      expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 1)\n    })\n\n    test('should return 400 if email is missing', async () => {\n      await insertUsers([userOne], config.database)\n\n      const res = await request('/v1/auth/forgot-password', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({})\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 204 if email does not belong to any user', async () => {\n      const res = await request('/v1/auth/forgot-password', {\n        method: 'POST',\n        body: JSON.stringify({ email: userOne.email }),\n        headers: { 'Content-Type': 'application/json' }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n    })\n  })\n\n  describe('POST /v1/auth/send-verification-email', () => {\n    const sesMock = mockClient(SESClient)\n\n    beforeEach(() => {\n      sesMock.reset()\n    })\n\n    test('should return 204 and send verification email to the user', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      sesMock.on(SendEmailCommand).resolves({\n        MessageId: 'message-id'\n      })\n\n      const res = await request('/v1/auth/send-verification-email', {\n        method: 'POST',\n        body: JSON.stringify({ email: userOne.email }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n      expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 1)\n    })\n\n    test('should return 204 and not send verification email if already verified', async () => {\n      const newUser = { ...userOne }\n      newUser.is_email_verified = true\n      await insertUsers([newUser], config.database)\n      const newUserAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n\n      sesMock.on(SendEmailCommand).resolves({\n        MessageId: 'message-id'\n      })\n\n      const res = await request('/v1/auth/send-verification-email', {\n        method: 'POST',\n        body: JSON.stringify({ email: newUser.email }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${newUserAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n      expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 0)\n    })\n\n    test('should return 429 if a second request is sent in under 2 minutes', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      sesMock.on(SendEmailCommand).resolves({\n        MessageId: 'message-id'\n      })\n\n      const res = await request('/v1/auth/send-verification-email', {\n        method: 'POST',\n        body: JSON.stringify({ email: userOne.email }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const res2 = await request('/v1/auth/send-verification-email', {\n        method: 'POST',\n        body: JSON.stringify({ email: userOne.email }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res2.status).toBe(httpStatus.TOO_MANY_REQUESTS)\n      expect(sesMock).toHaveReceivedCommandTimes(SendEmailCommand, 1)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      await insertUsers([userOne], config.database)\n\n      sesMock.on(SendEmailCommand).resolves({\n        MessageId: 'message-id'\n      })\n\n      const res = await request('/v1/auth/send-verification-email', {\n        method: 'POST',\n        body: JSON.stringify({ email: userOne.email }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n  })\n\n  describe('POST /v1/auth/reset-password', () => {\n    test('should return 204 and reset the password', async () => {\n      await insertUsers([userOne], config.database)\n      const newPassword = 'iamanewpassword123'\n      const expires = dayjs().add(config.jwt.resetPasswordExpirationMinutes, 'minutes')\n      const resetPasswordToken = await tokenService.generateToken(\n        userOne.id,\n        tokenTypes.RESET_PASSWORD,\n        userOne.role,\n        expires,\n        config.jwt.secret,\n        userOne.is_email_verified\n      )\n      const res = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {\n        method: 'POST',\n        body: JSON.stringify({ password: newPassword }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      const isPasswordMatch = await bcrypt.compare(newPassword, dbUser.password || '')\n      expect(isPasswordMatch).toBe(true)\n    })\n\n    test('should return 400 if reset password token is missing', async () => {\n      const res = await request('/v1/auth/reset-password', {\n        method: 'POST',\n        body: JSON.stringify({ password: 'iamanewpasword123' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 401 if reset password token is expired', async () => {\n      await insertUsers([userOne], config.database)\n      const newPassword = 'iamanewpassword123'\n      const expires = dayjs().subtract(10, 'minutes')\n      const resetPasswordToken = await tokenService.generateToken(\n        userOne.id,\n        tokenTypes.RESET_PASSWORD,\n        userOne.role,\n        expires,\n        config.jwt.secret,\n        userOne.is_email_verified\n      )\n      const res = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {\n        method: 'POST',\n        body: JSON.stringify({ password: newPassword }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 401 if user is not found', async () => {\n      const newPassword = 'iamanewpassword123'\n      const expires = dayjs().add(config.jwt.resetPasswordExpirationMinutes, 'minutes')\n      const resetPasswordToken = await tokenService.generateToken(\n        '123',\n        tokenTypes.RESET_PASSWORD,\n        userOne.role,\n        expires,\n        config.jwt.secret,\n        userOne.is_email_verified\n      )\n      const res = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {\n        method: 'POST',\n        body: JSON.stringify({ password: newPassword }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 400 if password is missing or invalid', async () => {\n      await insertUsers([userOne], config.database)\n      const expires = dayjs().add(config.jwt.resetPasswordExpirationMinutes, 'minutes')\n      const resetPasswordToken = await tokenService.generateToken(\n        userOne.id,\n        tokenTypes.RESET_PASSWORD,\n        userOne.role,\n        expires,\n        config.jwt.secret,\n        userOne.is_email_verified\n      )\n      const res = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n\n      const res2 = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {\n        method: 'POST',\n        body: JSON.stringify({ password: 'short1' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res2.status).toBe(httpStatus.BAD_REQUEST)\n\n      const res3 = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {\n        method: 'POST',\n        body: JSON.stringify({ password: 'password' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res3.status).toBe(httpStatus.BAD_REQUEST)\n\n      const res4 = await request(`/v1/auth/reset-password?token=${resetPasswordToken}`, {\n        method: 'POST',\n        body: JSON.stringify({ password: '11111111' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res4.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n\n  describe('POST /v1/auth/verify-email', () => {\n    test('should return 204 and verify the email', async () => {\n      await insertUsers([userOne], config.database)\n      const expires = dayjs().add(config.jwt.verifyEmailExpirationMinutes, 'minutes')\n      const verifyEmailToken = await tokenService.generateToken(\n        userOne.id,\n        tokenTypes.VERIFY_EMAIL,\n        userOne.role,\n        expires,\n        config.jwt.secret,\n        userOne.is_email_verified\n      )\n      const res = await request(`/v1/auth/verify-email?token=${verifyEmailToken}`, {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n      expect(dbUser.is_email_verified).toBe(1)\n    })\n\n    test('should return 400 if verify email token is missing', async () => {\n      const res = await request('/v1/auth/verify-email', {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 401 if verify email token is expired', async () => {\n      await insertUsers([userOne], config.database)\n      const expires = dayjs().subtract(10, 'minutes')\n      const verifyEmailToken = await tokenService.generateToken(\n        userOne.id,\n        tokenTypes.VERIFY_EMAIL,\n        userOne.role,\n        expires,\n        config.jwt.secret,\n        userOne.is_email_verified\n      )\n      const res = await request(`/v1/auth/verify-email?token=${verifyEmailToken}`, {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 401 if verify email token is an access token', async () => {\n      await insertUsers([userOne], config.database)\n      const expires = dayjs().add(10, 'minutes')\n      const verifyEmailToken = await tokenService.generateToken(\n        userOne.id,\n        tokenTypes.ACCESS,\n        userOne.role,\n        expires,\n        config.jwt.secret,\n        userOne.is_email_verified\n      )\n      const res = await request(`/v1/auth/verify-email?token=${verifyEmailToken}`, {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 401 if user is not found', async () => {\n      const expires = dayjs().add(config.jwt.verifyEmailExpirationMinutes, 'minutes')\n      const verifyEmailToken = await tokenService.generateToken(\n        '123',\n        tokenTypes.VERIFY_EMAIL,\n        userOne.role,\n        expires,\n        config.jwt.secret,\n        userOne.is_email_verified\n      )\n      const res = await request(`/v1/auth/verify-email?token=${verifyEmailToken}`, {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n  })\n  describe('GET /v1/auth/authorisations', () => {\n    test('should 200 and list of user authentication methods with local true', async () => {\n      await insertUsers([userOne], config.database)\n      const accessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const res = await request('/v1/auth/authorisations', {\n        method: 'GET',\n        headers: {\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      const body = await res.json()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toEqual({\n        local: true,\n        facebook: false,\n        github: false,\n        google: false,\n        spotify: false,\n        discord: false,\n        apple: false\n      })\n    })\n\n    test('should 200 and list of user authentication methods with discord true', async () => {\n      const user = { ...userOne }\n      user.password = null\n      await insertUsers([user], config.database)\n      const discordAuth = discordAuthorisation(user.id)\n      await insertAuthorisations([discordAuth], config.database)\n      const accessToken = await getAccessToken(user.id, user.role, config.jwt)\n      const res = await request('/v1/auth/authorisations', {\n        method: 'GET',\n        headers: {\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      const body = await res.json()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toEqual({\n        local: false,\n        facebook: false,\n        github: false,\n        google: false,\n        spotify: false,\n        discord: true,\n        apple: false\n      })\n    })\n    test('should 200 and list of user authentication methods with all true', async () => {\n      await insertUsers([userOne], config.database)\n      const discordAuth = discordAuthorisation(userOne.id)\n      const spotifyAuth = spotifyAuthorisation(userOne.id)\n      const googleAuth = googleAuthorisation(userOne.id)\n      const githubAuth = githubAuthorisation(userOne.id)\n      const facebookAuth = facebookAuthorisation(userOne.id)\n      const appleAuth = appleAuthorisation(userOne.id)\n      await insertAuthorisations(\n        [discordAuth, spotifyAuth, googleAuth, facebookAuth, githubAuth, appleAuth],\n        config.database\n      )\n      const accessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const res = await request('/v1/auth/authorisations', {\n        method: 'GET',\n        headers: {\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      const body = await res.json()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toEqual({\n        local: true,\n        facebook: true,\n        github: true,\n        google: true,\n        spotify: true,\n        discord: true,\n        apple: true\n      })\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/auth/authorisations', {\n        method: 'GET',\n        headers: {\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n  })\n  describe('Auth middleware', () => {\n    test('should return 401 if auth header is malformed', async () => {\n      const res = await request('/v1/users/123', {\n        method: 'GET',\n        headers: {\n          Authorization: 'Bearer123'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/auth/oauth/apple.test.ts",
    "content": "import { faker } from '@faker-js/faker'\nimport jwt from '@tsndr/cloudflare-worker-jwt'\nimport { env, fetchMock } from 'cloudflare:test'\nimport httpStatus from 'http-status'\nimport { describe, expect, test, beforeAll, afterEach } from 'vitest'\nimport { authProviders } from '../../../../src/config/authProviders'\nimport { getConfig } from '../../../../src/config/config'\nimport { getDBClient } from '../../../../src/config/database'\nimport { tokenTypes } from '../../../../src/config/tokens'\nimport { AppleUserType } from '../../../../src/types/oauth.types'\nimport {\n  appleAuthorisation,\n  githubAuthorisation,\n  googleAuthorisation,\n  insertAuthorisations\n} from '../../../fixtures/authorisations.fixture'\nimport { getAccessToken } from '../../../fixtures/token.fixture'\nimport { userOne, insertUsers, userTwo } from '../../../fixtures/user.fixture'\nimport { clearDBTables } from '../../../utils/clear-db-tables'\nimport { request } from '../../../utils/test-request'\n\nconst config = getConfig(env)\nconst client = getDBClient(config.database)\n\nclearDBTables(['authorisations', 'user'], config.database)\n\ndescribe('Oauth Apple routes', () => {\n  describe('GET /v1/auth/apple/redirect', () => {\n    test('should return 302 and successfully redirect to apple', async () => {\n      const state = btoa(JSON.stringify({ platform: 'web' }))\n      const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.provider.apple.redirectUrl)\n      const res = await request(`/v1/auth/apple/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toContain(\n        'https://appleid.apple.com/auth/authorize?client_id=myclientid&redirect_uri=' +\n          `${urlEncodedRedirectUrl}&response_mode=form_post&response_type=code&scope=email` +\n          `&state=${state}`\n      )\n    })\n    test('should return 400 error if state is not provided', async () => {\n      const res = await request('/v1/auth/apple/redirect', { method: 'GET' })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if state platform is not provided', async () => {\n      const state = btoa(JSON.stringify({}))\n      const res = await request(`/v1/auth/apple/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if state platform is invalid', async () => {\n      const state = btoa(JSON.stringify({ platform: 'fake' }))\n      const res = await request(`/v1/auth/apple/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n\n  describe('POST /v1/auth/apple/callback', () => {\n    let newUser: AppleUserType\n    let state: string\n    beforeAll(async () => {\n      newUser = {\n        sub: faker.number.int().toString(),\n        name: faker.person.fullName(),\n        email: faker.internet.email()\n      }\n      fetchMock.activate()\n      state = btoa(JSON.stringify({ platform: 'web' }))\n    })\n    afterEach(() => fetchMock.assertNoPendingInterceptors())\n    test('should return 200 and successfully register + redirect with one time code', async () => {\n      const mockJWT = await jwt.sign(newUser, 'randomSecret')\n      const appleMock = fetchMock.get('https://appleid.apple.com')\n      appleMock\n        .intercept({ method: 'POST', path: '/auth/token' })\n        .reply(200, JSON.stringify({ id_token: mockJWT }))\n      const providerId = '123456'\n      const formData = new FormData()\n      formData.append('code', providerId)\n      formData.append('state', state)\n      const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toContain(\n        `${config.oauth.platform.ios.redirectUrl}?oneTimeCode=`\n      )\n      const location = res.headers.get('location')\n      const oneTimeCode = location?.split('=')[1].split('&')[0]\n      expect(oneTimeCode).toBeDefined()\n      if (!oneTimeCode) return\n\n      const returnedState = location?.split('=')[2]\n      expect(returnedState).toBe(state)\n\n      const dbOneTimeCode = await client\n        .selectFrom('one_time_oauth_code')\n        .selectAll()\n        .where('one_time_oauth_code.code', '=', oneTimeCode)\n        .executeTakeFirst()\n\n      expect(dbOneTimeCode).toBeDefined()\n      if (!dbOneTimeCode) return\n\n      expect(dbOneTimeCode).toEqual({\n        code: oneTimeCode,\n        user_id: expect.any(String),\n        access_token: expect.any(String),\n        access_token_expires_at: expect.any(Date),\n        refresh_token: expect.any(String),\n        refresh_token_expires_at: expect.any(Date),\n        expires_at: expect.any(Date),\n        created_at: expect.any(Date),\n        updated_at: expect.any(Date)\n      })\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', dbOneTimeCode.user_id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).toBeNull()\n      expect(dbUser).toMatchObject({\n        name: newUser.name,\n        password: null,\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 1\n      })\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.APPLE)\n        .where('authorisations.user_id', '=', dbOneTimeCode.user_id)\n        .where('authorisations.provider_user_id', '=', newUser.sub)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n      if (!oauthUser) return\n    })\n\n    test('should redirect and successfully login user if already created', async () => {\n      await insertUsers([userOne], config.database)\n      const appleUser = appleAuthorisation(userOne.id)\n      await insertAuthorisations([appleUser], config.database)\n      newUser.sub = appleUser.provider_user_id\n\n      const mockJWT = await jwt.sign(newUser, 'randomSecret')\n      const appleMock = fetchMock.get('https://appleid.apple.com')\n      appleMock\n        .intercept({ method: 'POST', path: '/auth/token' })\n        .reply(200, JSON.stringify({ id_token: mockJWT }))\n\n      const providerId = '123456'\n      const formData = new FormData()\n      formData.append('code', providerId)\n      formData.append('state', state)\n      const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toContain(\n        `${config.oauth.platform.ios.redirectUrl}?oneTimeCode=`\n      )\n      expect(res.headers.get('location')).toContain(`state=${state}`)\n    })\n    test('should redirect with error if user exists but has not linked their apple', async () => {\n      await insertUsers([userOne], config.database)\n      newUser.email = userOne.email\n      const mockJWT = await jwt.sign(newUser, 'randomSecret')\n      const appleMock = fetchMock.get('https://appleid.apple.com')\n      appleMock\n        .intercept({ method: 'POST', path: '/auth/token' })\n        .reply(200, JSON.stringify({ id_token: mockJWT }))\n\n      const providerId = '123456'\n      const formData = new FormData()\n      formData.append('code', providerId)\n      formData.append('state', state)\n      const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })\n      expect(res.status).toBe(httpStatus.FOUND)\n      const encodedError =\n        'Cannot%20signup%20with%20apple,' + '%20user%20already%20exists%20with%20that%20email'\n      expect(res.headers.get('location')).toBe(\n        `${config.oauth.platform.ios.redirectUrl}?error=${encodedError}&state=${state}`\n      )\n    })\n    //TODO: return custom error message for this scenario\n    test('should redirect with error if no apple email is provided', async () => {\n      delete newUser.email\n      const mockJWT = await jwt.sign(newUser, 'randomSecret')\n      const appleMock = fetchMock.get('https://appleid.apple.com')\n      appleMock\n        .intercept({ method: 'POST', path: '/auth/token' })\n        .reply(200, JSON.stringify({ id_token: mockJWT }))\n      const providerId = '123456'\n      const formData = new FormData()\n      formData.append('state', state)\n      formData.append('code', providerId)\n      const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toBe(\n        `${config.oauth.platform.ios.redirectUrl}?error=Unauthorized&state=${state}`\n      )\n    })\n    test('should redirect with error if code is invalid', async () => {\n      const appleMock = fetchMock.get('https://appleid.apple.com')\n      appleMock\n        .intercept({ method: 'POST', path: '/auth/token' })\n        .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))\n\n      const providerId = '123456'\n      const formData = new FormData()\n      formData.append('code', providerId)\n      formData.append('state', state)\n      const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toBe(\n        `${config.oauth.platform.ios.redirectUrl}?error=Unauthorized&state=${state}`\n      )\n    })\n\n    test('should redirect with error if no code provided', async () => {\n      const formData = new FormData()\n      formData.append('state', state)\n      const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toBe(\n        `${config.oauth.platform.ios.redirectUrl}?error=Bad%20request&state=${state}`\n      )\n    })\n    test('should return 400 error if state is not provided', async () => {\n      const providerId = '123456'\n      const formData = new FormData()\n      formData.append('code', providerId)\n      const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toBe(\n        `${config.oauth.platform.web.redirectUrl}?error=Something%20went%20wrong`\n      )\n    })\n    test('should return 400 error if platform is not provided', async () => {\n      const providerId = '123456'\n      const formData = new FormData()\n      state = btoa(JSON.stringify({}))\n      formData.append('code', providerId)\n      formData.append('state', state)\n      const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toBe(\n        `${config.oauth.platform.web.redirectUrl}?error=Bad%20request&state=${state}`\n      )\n    })\n    test('should return 400 error if platform is invalid', async () => {\n      const providerId = '123456'\n      const formData = new FormData()\n      state = btoa(JSON.stringify({ platform: 'wb' }))\n      formData.append('code', providerId)\n      formData.append('state', state)\n      const res = await request('/v1/auth/apple/callback', { method: 'POST', body: formData })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toBe(\n        `${config.oauth.platform.web.redirectUrl}?error=Bad%20request&state=${state}`\n      )\n    })\n  })\n\n  describe('POST /v1/auth/apple/:userId', () => {\n    let newUser: AppleUserType\n    beforeAll(async () => {\n      newUser = {\n        sub: faker.number.int().toString(),\n        name: faker.person.fullName(),\n        email: faker.internet.email()\n      }\n      fetchMock.activate()\n    })\n    afterEach(() => fetchMock.assertNoPendingInterceptors())\n    test('should return 200 and successfully link apple account', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const mockJWT = await jwt.sign(newUser, 'randomSecret')\n      const appleMock = fetchMock.get('https://appleid.apple.com')\n      appleMock\n        .intercept({ method: 'POST', path: '/auth/token' })\n        .reply(200, JSON.stringify({ id_token: mockJWT }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/apple/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).toBeDefined()\n      expect(dbUser).toMatchObject({\n        name: userOne.name,\n        password: expect.anything(),\n        email: userOne.email,\n        role: userOne.role,\n        is_email_verified: 0\n      })\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.APPLE)\n        .where('authorisations.user_id', '=', userOne.id)\n        .where('authorisations.provider_user_id', '=', newUser.sub)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n      if (!oauthUser) return\n    })\n\n    test('should return 401 if user does not exist when linking', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      await client.deleteFrom('user').where('user.id', '=', userOne.id).execute()\n\n      const mockJWT = await jwt.sign(newUser, 'randomSecret')\n      const appleMock = fetchMock.get('https://appleid.apple.com')\n      appleMock\n        .intercept({ method: 'POST', path: '/auth/token' })\n        .reply(200, JSON.stringify({ id_token: mockJWT }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/apple/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.APPLE)\n        .where('authorisations.user_id', '=', userOne.id)\n        .where('authorisations.provider_user_id', '=', String(newUser.sub))\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeUndefined()\n    })\n\n    test('should return 401 if code is invalid', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const appleMock = fetchMock.get('https://appleid.apple.com')\n      appleMock\n        .intercept({ method: 'POST', path: '/auth/token' })\n        .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/apple/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 403 if linking different user', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/apple/5298', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 400 if no code provided', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const res = await request(`/v1/auth/apple/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      const res = await request('/v1/auth/apple/1234', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/auth/apple/5298', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n  })\n\n  describe('DELETE /v1/auth/apple/:userId', () => {\n    test('should return 200 and successfully remove apple account link', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const appleUser = appleAuthorisation(userOne.id)\n      await insertAuthorisations([appleUser], config.database)\n\n      const res = await request(`/v1/auth/apple/${userOne.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.APPLE)\n        .where('authorisations.user_id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeUndefined()\n      if (!oauthUser) return\n    })\n\n    test('should return 400 if user does not have a local login and only 1 link', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const appleUser = appleAuthorisation(newUser.id)\n      await insertAuthorisations([appleUser], config.database)\n\n      const res = await request(`/v1/auth/apple/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.APPLE)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n    })\n\n    test('should return 400 if user does not have apple link', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const githubUser = githubAuthorisation(newUser.id)\n      await insertAuthorisations([githubUser], config.database)\n      const googleUser = googleAuthorisation(newUser.id)\n      await insertAuthorisations([googleUser], config.database)\n\n      const res = await request(`/v1/auth/apple/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 if user only has a local login', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n\n      const res = await request(`/v1/auth/apple/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 200 if user does not have a local login and 2 links', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const appleUser = appleAuthorisation(newUser.id)\n      const githubUser = githubAuthorisation(newUser.id)\n      await insertAuthorisations([appleUser, githubUser], config.database)\n\n      const res = await request(`/v1/auth/apple/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const oauthAppleUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.APPLE)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthAppleUser).toBeUndefined()\n\n      const oauthFacebookUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GITHUB)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthFacebookUser).toBeDefined()\n    })\n\n    test('should return 403 if unlinking different user', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const res = await request('/v1/auth/apple/5298', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      const res = await request('/v1/auth/apple/1234', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/auth/apple/5298', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/auth/oauth/discord.test.ts",
    "content": "import { faker } from '@faker-js/faker'\nimport { env, fetchMock } from 'cloudflare:test'\nimport httpStatus from 'http-status'\nimport { describe, expect, test, beforeAll, afterEach } from 'vitest'\nimport { authProviders } from '../../../../src/config/authProviders'\nimport { getConfig } from '../../../../src/config/config'\nimport { getDBClient } from '../../../../src/config/database'\nimport { tokenTypes } from '../../../../src/config/tokens'\nimport { DiscordUserType } from '../../../../src/types/oauth.types'\nimport {\n  discordAuthorisation,\n  facebookAuthorisation,\n  githubAuthorisation,\n  insertAuthorisations\n} from '../../../fixtures/authorisations.fixture'\nimport { getAccessToken, TokenResponse } from '../../../fixtures/token.fixture'\nimport { userOne, insertUsers, UserResponse, userTwo } from '../../../fixtures/user.fixture'\nimport { clearDBTables } from '../../../utils/clear-db-tables'\nimport { request } from '../../../utils/test-request'\n\nconst config = getConfig(env)\nconst client = getDBClient(config.database)\n\nclearDBTables(['user', 'authorisations'], config.database)\n\ndescribe('Oauth Discord routes', () => {\n  describe('GET /v1/auth/discord/redirect', () => {\n    test('should return 302 and successfully redirect to discord', async () => {\n      const state = btoa(JSON.stringify({ platform: 'web' }))\n      const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.platform.web.redirectUrl)\n      const res = await request(`/v1/auth/discord/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toBe(\n        'https://discord.com/api/oauth2/authorize?client_id=' +\n          `${config.oauth.provider.discord.clientId}&redirect_uri=${urlEncodedRedirectUrl}&` +\n          `response_type=code&scope=identify%20email&state=${state}`\n      )\n    })\n    test('should return 400 error if state is not provided', async () => {\n      const res = await request('/v1/auth/discord/redirect', { method: 'GET' })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if state platform is not provided', async () => {\n      const state = btoa(JSON.stringify({}))\n      const res = await request(`/v1/auth/discord/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if state platform is invalid', async () => {\n      const state = btoa(JSON.stringify({ platform: 'fake' }))\n      const res = await request(`/v1/auth/discord/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n\n  describe('POST /v1/auth/discord/callback', () => {\n    let newUser: DiscordUserType\n    beforeAll(async () => {\n      newUser = {\n        id: faker.number.int().toString(),\n        username: faker.person.fullName(),\n        email: faker.internet.email()\n      }\n      fetchMock.activate()\n    })\n    afterEach(() => fetchMock.assertNoPendingInterceptors())\n    test('should return 200 and successfully register user if request data is ok', async () => {\n      const discordApiMock = fetchMock.get('https://discord.com')\n      discordApiMock\n        .intercept({ method: 'GET', path: '/api/users/@me' })\n        .reply(200, JSON.stringify(newUser))\n      const discordMock = fetchMock.get('https://discordapp.com')\n      discordMock\n        .intercept({ method: 'POST', path: '/api/oauth2/token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/discord/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body.user).not.toHaveProperty('password')\n      expect(body.user).toEqual({\n        id: expect.anything(),\n        name: newUser.username,\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 1\n      })\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', body.user.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).toBeNull()\n      expect(dbUser).toMatchObject({\n        name: newUser.username,\n        password: null,\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 1\n      })\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.DISCORD)\n        .where('authorisations.user_id', '=', body.user.id)\n        .where('authorisations.provider_user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n      if (!oauthUser) return\n\n      expect(body.tokens).toEqual({\n        access: { token: expect.anything(), expires: expect.anything() },\n        refresh: { token: expect.anything(), expires: expect.anything() }\n      })\n    })\n\n    test('should return 200 and successfully login user if already created', async () => {\n      await insertUsers([userOne], config.database)\n      const discordUser = discordAuthorisation(userOne.id)\n      await insertAuthorisations([discordUser], config.database)\n      newUser.id = discordUser.provider_user_id\n      const discordApiMock = fetchMock.get('https://discord.com')\n      discordApiMock\n        .intercept({ method: 'GET', path: '/api/users/@me' })\n        .reply(200, JSON.stringify(newUser))\n      const discordMock = fetchMock.get('https://discordapp.com')\n      discordMock\n        .intercept({ method: 'POST', path: '/api/oauth2/token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/discord/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body.user).not.toHaveProperty('password')\n      expect(body.user).toEqual({\n        id: userOne.id,\n        name: userOne.name,\n        email: userOne.email,\n        role: userOne.role,\n        is_email_verified: 0\n      })\n\n      expect(body.tokens).toEqual({\n        access: { token: expect.anything(), expires: expect.anything() },\n        refresh: { token: expect.anything(), expires: expect.anything() }\n      })\n    })\n\n    test('should return 403 if user exists but has not linked their discord', async () => {\n      await insertUsers([userOne], config.database)\n      newUser.email = userOne.email\n      const discordApiMock = fetchMock.get('https://discord.com')\n      discordApiMock\n        .intercept({ method: 'GET', path: '/api/users/@me' })\n        .reply(200, JSON.stringify(newUser))\n      const discordMock = fetchMock.get('https://discordapp.com')\n      discordMock\n        .intercept({ method: 'POST', path: '/api/oauth2/token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/discord/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n      expect(body).toEqual({\n        code: httpStatus.FORBIDDEN,\n        message: 'Cannot signup with discord, user already exists with that email'\n      })\n    })\n\n    test('should return 401 if code is invalid', async () => {\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const discordMock = fetchMock.get('https://discordapp.com')\n      discordMock\n        .intercept({ method: 'POST', path: '/api/oauth2/token' })\n        .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/discord/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 400 if no code provided', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const res = await request('/v1/auth/discord/callback', {\n        method: 'POST',\n        body: JSON.stringify({ platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is not provided', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const res = await request('/v1/auth/discord/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is invalid', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const res = await request('/v1/auth/discord/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'wb' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n\n  describe('POST /v1/auth/discord/:userId', () => {\n    let newUser: DiscordUserType\n    beforeAll(async () => {\n      newUser = {\n        id: faker.number.int().toString(),\n        username: faker.person.fullName(),\n        email: faker.internet.email()\n      }\n      fetchMock.activate()\n    })\n    afterEach(() => fetchMock.assertNoPendingInterceptors())\n    test('should return 200 and successfully link discord account', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const discordApiMock = fetchMock.get('https://discord.com')\n      discordApiMock\n        .intercept({ method: 'GET', path: '/api/users/@me' })\n        .reply(200, JSON.stringify(newUser))\n      const discordMock = fetchMock.get('https://discordapp.com')\n      discordMock\n        .intercept({ method: 'POST', path: '/api/oauth2/token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/discord/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).toBeDefined()\n      expect(dbUser).toMatchObject({\n        name: userOne.name,\n        password: expect.anything(),\n        email: userOne.email,\n        role: userOne.role,\n        is_email_verified: 0\n      })\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.DISCORD)\n        .where('authorisations.user_id', '=', userOne.id)\n        .where('authorisations.provider_user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n      if (!oauthUser) return\n    })\n\n    test('should return 401 if user does not exist when linking', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      await client.deleteFrom('user').where('user.id', '=', userOne.id).execute()\n      const discordApiMock = fetchMock.get('https://discord.com')\n      discordApiMock\n        .intercept({ method: 'GET', path: '/api/users/@me' })\n        .reply(200, JSON.stringify(newUser))\n      const discordMock = fetchMock.get('https://discordapp.com')\n      discordMock\n        .intercept({ method: 'POST', path: '/api/oauth2/token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/discord/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.DISCORD)\n        .where('authorisations.user_id', '=', userOne.id)\n        .where('authorisations.provider_user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeUndefined()\n    })\n\n    test('should return 401 if code is invalid', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const discordMock = fetchMock.get('https://discordapp.com')\n      discordMock\n        .intercept({ method: 'POST', path: '/api/oauth2/token' })\n        .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/discord/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 403 if linking different user', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/discord/5298', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 400 if no code provided', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const res = await request(`/v1/auth/discord/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      const res = await request('/v1/auth/discord/1234', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/auth/discord/5298', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n    test('should return 400 error if platform is not provided', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const res = await request(`/v1/auth/discord/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is invalid', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const res = await request(`/v1/auth/discord/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'wb' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n  describe('DELETE /v1/auth/discord/:userId', () => {\n    test('should return 200 and successfully remove discord account link', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const discordUser = discordAuthorisation(userOne.id)\n      await insertAuthorisations([discordUser], config.database)\n\n      const res = await request(`/v1/auth/discord/${userOne.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.DISCORD)\n        .where('authorisations.user_id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeUndefined()\n      if (!oauthUser) return\n    })\n\n    test('should return 400 if user does not have a local login and only 1 link', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const discordUser = discordAuthorisation(newUser.id)\n      await insertAuthorisations([discordUser], config.database)\n\n      const res = await request(`/v1/auth/discord/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.DISCORD)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n    })\n\n    test('should return 400 if user does not have discord link', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const githubUser = githubAuthorisation(newUser.id)\n      await insertAuthorisations([githubUser], config.database)\n      const facebookUser = facebookAuthorisation(newUser.id)\n      await insertAuthorisations([facebookUser], config.database)\n\n      const res = await request(`/v1/auth/discord/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 if user only has a local login', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n\n      const res = await request(`/v1/auth/discord/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 200 if user does not have a local login and 2 links', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const discordUser = discordAuthorisation(newUser.id)\n      const facebookUser = facebookAuthorisation(newUser.id)\n      await insertAuthorisations([discordUser, facebookUser], config.database)\n\n      const res = await request(`/v1/auth/discord/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const oauthDiscordUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.DISCORD)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthDiscordUser).toBeUndefined()\n\n      const oauthFacebookUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.FACEBOOK)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthFacebookUser).toBeDefined()\n    })\n\n    test('should return 403 if unlinking different user', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const res = await request('/v1/auth/discord/5298', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      const res = await request('/v1/auth/discord/1234', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/auth/discord/5298', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/auth/oauth/facebook.test.ts",
    "content": "import { faker } from '@faker-js/faker'\nimport { env, fetchMock } from 'cloudflare:test'\nimport httpStatus from 'http-status'\nimport { describe, expect, test, beforeAll, afterEach } from 'vitest'\nimport { authProviders } from '../../../../src/config/authProviders'\nimport { getConfig } from '../../../../src/config/config'\nimport { getDBClient } from '../../../../src/config/database'\nimport { tokenTypes } from '../../../../src/config/tokens'\nimport { FacebookUserType } from '../../../../src/types/oauth.types'\nimport {\n  facebookAuthorisation,\n  githubAuthorisation,\n  googleAuthorisation,\n  insertAuthorisations\n} from '../../../fixtures/authorisations.fixture'\nimport { getAccessToken, TokenResponse } from '../../../fixtures/token.fixture'\nimport { userOne, insertUsers, UserResponse, userTwo } from '../../../fixtures/user.fixture'\nimport { clearDBTables } from '../../../utils/clear-db-tables'\nimport { request } from '../../../utils/test-request'\n\nconst config = getConfig(env)\nconst client = getDBClient(config.database)\n\nclearDBTables(['user', 'authorisations'], config.database)\n\ndescribe('Oauth Facebook routes', () => {\n  describe('GET /v1/auth/facebook/redirect', () => {\n    test('should return 302 and successfully redirect to facebook', async () => {\n      const state = btoa(JSON.stringify({ platform: 'web' }))\n      const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.platform.web.redirectUrl)\n      const res = await request(`/v1/auth/facebook/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toBe(\n        'https://www.facebook.com/v4.0/dialog/oauth?auth_type=rerequest&' +\n          `client_id=${config.oauth.provider.facebook.clientId}&display=popup&` +\n          `redirect_uri=${urlEncodedRedirectUrl}&response_type=code&scope=email%2C%20user_friends` +\n          `&state=${state}`\n      )\n    })\n    test('should return 400 error if state is not provided', async () => {\n      const res = await request('/v1/auth/facebook/redirect', { method: 'GET' })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if state platform is not provided', async () => {\n      const state = btoa(JSON.stringify({}))\n      const res = await request(`/v1/auth/facebook/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if state platform is invalid', async () => {\n      const state = btoa(JSON.stringify({ platform: 'fake' }))\n      const res = await request(`/v1/auth/facebook/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n\n  describe('POST /v1/auth/facebook/callback', () => {\n    let newUser: FacebookUserType\n    beforeAll(async () => {\n      newUser = {\n        id: faker.number.int().toString(),\n        first_name: faker.person.firstName(),\n        last_name: faker.person.lastName(),\n        email: faker.internet.email()\n      }\n      fetchMock.activate()\n    })\n    afterEach(() => fetchMock.assertNoPendingInterceptors())\n    test('should return 200 and successfully register user if request data is ok', async () => {\n      const facebookApiMock = fetchMock.get('https://graph.facebook.com')\n      facebookApiMock\n        .intercept({\n          method: 'GET',\n          path: '/me?fields=id,email,first_name,last_name&access_token=1234'\n        })\n        .reply(200, JSON.stringify(newUser))\n      facebookApiMock\n        .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/facebook/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body.user).not.toHaveProperty('password')\n      expect(body.user).toEqual({\n        id: expect.anything(),\n        name: `${newUser.first_name} ${newUser.last_name}`,\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 1\n      })\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', body.user.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).toBeNull()\n      expect(dbUser).toMatchObject({\n        name: `${newUser.first_name} ${newUser.last_name}`,\n        password: null,\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 1\n      })\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.FACEBOOK)\n        .where('authorisations.user_id', '=', body.user.id)\n        .where('authorisations.provider_user_id', '=', String(newUser.id))\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n      if (!oauthUser) return\n\n      expect(body.tokens).toEqual({\n        access: { token: expect.anything(), expires: expect.anything() },\n        refresh: { token: expect.anything(), expires: expect.anything() }\n      })\n    })\n\n    test('should return 200 and successfully login user if already created', async () => {\n      await insertUsers([userOne], config.database)\n      const facebookUser = facebookAuthorisation(userOne.id)\n      await insertAuthorisations([facebookUser], config.database)\n      newUser.id = facebookUser.provider_user_id\n      const facebookApiMock = fetchMock.get('https://graph.facebook.com')\n      facebookApiMock\n        .intercept({\n          method: 'GET',\n          path: '/me?fields=id,email,first_name,last_name&access_token=1234'\n        })\n        .reply(200, JSON.stringify(newUser))\n      facebookApiMock\n        .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/facebook/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body.user).not.toHaveProperty('password')\n      expect(body.user).toEqual({\n        id: userOne.id,\n        name: userOne.name,\n        email: userOne.email,\n        role: userOne.role,\n        is_email_verified: 0\n      })\n\n      expect(body.tokens).toEqual({\n        access: { token: expect.anything(), expires: expect.anything() },\n        refresh: { token: expect.anything(), expires: expect.anything() }\n      })\n    })\n\n    test('should return 403 if user exists but has not linked their facebook', async () => {\n      await insertUsers([userOne], config.database)\n      newUser.email = userOne.email\n      const facebookApiMock = fetchMock.get('https://graph.facebook.com')\n      facebookApiMock\n        .intercept({\n          method: 'GET',\n          path: '/me?fields=id,email,first_name,last_name&access_token=1234'\n        })\n        .reply(200, JSON.stringify(newUser))\n      facebookApiMock\n        .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/facebook/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n      expect(body).toEqual({\n        code: httpStatus.FORBIDDEN,\n        message: 'Cannot signup with facebook, user already exists with that email'\n      })\n    })\n\n    test('should return 401 if code is invalid', async () => {\n      const facebookApiMock = fetchMock.get('https://graph.facebook.com')\n      facebookApiMock\n        .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' })\n        .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/facebook/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 400 if no code provided', async () => {\n      const res = await request('/v1/auth/facebook/callback', {\n        method: 'POST',\n        body: JSON.stringify({ platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is not provided', async () => {\n      const providerId = '123456'\n      const res = await request('/v1/auth/facebook/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is invalid', async () => {\n      const providerId = '123456'\n      const res = await request('/v1/auth/facebook/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'wb' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n  describe('POST /v1/auth/facebook/:userId', () => {\n    let newUser: FacebookUserType\n    beforeAll(async () => {\n      newUser = {\n        id: faker.number.int().toString(),\n        first_name: faker.person.firstName(),\n        last_name: faker.person.lastName(),\n        email: faker.internet.email()\n      }\n      fetchMock.activate()\n    })\n    afterEach(() => fetchMock.assertNoPendingInterceptors())\n    test('should return 200 and successfully link facebook account', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const facebookApiMock = fetchMock.get('https://graph.facebook.com')\n      facebookApiMock\n        .intercept({\n          method: 'GET',\n          path: '/me?fields=id,email,first_name,last_name&access_token=1234'\n        })\n        .reply(200, JSON.stringify(newUser))\n      facebookApiMock\n        .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/facebook/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).toBeDefined()\n      expect(dbUser).toMatchObject({\n        name: userOne.name,\n        password: expect.anything(),\n        email: userOne.email,\n        role: userOne.role,\n        is_email_verified: 0\n      })\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.FACEBOOK)\n        .where('authorisations.user_id', '=', userOne.id)\n        .where('authorisations.provider_user_id', '=', String(newUser.id))\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n      if (!oauthUser) return\n    })\n\n    test('should return 401 if user does not exist when linking', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      await client.deleteFrom('user').where('user.id', '=', userOne.id).execute()\n      const facebookApiMock = fetchMock.get('https://graph.facebook.com')\n      facebookApiMock\n        .intercept({\n          method: 'GET',\n          path: '/me?fields=id,email,first_name,last_name&access_token=1234'\n        })\n        .reply(200, JSON.stringify(newUser))\n      facebookApiMock\n        .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/facebook/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.FACEBOOK)\n        .where('authorisations.user_id', '=', userOne.id)\n        .where('authorisations.provider_user_id', '=', String(newUser.id))\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeUndefined()\n    })\n\n    test('should return 401 if code is invalid', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const facebookApiMock = fetchMock.get('https://graph.facebook.com')\n      facebookApiMock\n        .intercept({ method: 'POST', path: '/v4.0/oauth/access_token' })\n        .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/facebook/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 403 if linking different user', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/facebook/5298', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 400 if no code provided', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const res = await request(`/v1/auth/facebook/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      const res = await request('/v1/auth/facebook/1234', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/auth/facebook/5298', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n    test('should return 400 error if platform is not provided', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const res = await request(`/v1/auth/facebook/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is invalid', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const res = await request(`/v1/auth/facebook/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'wb' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n\n  describe('DELETE /v1/auth/facebook/:userId', () => {\n    test('should return 200 and successfully remove facebook account link', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const facebookUser = facebookAuthorisation(userOne.id)\n      await insertAuthorisations([facebookUser], config.database)\n\n      const res = await request(`/v1/auth/facebook/${userOne.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.FACEBOOK)\n        .where('authorisations.user_id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeUndefined()\n      if (!oauthUser) return\n    })\n\n    test('should return 400 if user does not have a local login and only 1 link', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const facebookUser = facebookAuthorisation(newUser.id)\n      await insertAuthorisations([facebookUser], config.database)\n\n      const res = await request(`/v1/auth/facebook/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.FACEBOOK)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n    })\n\n    test('should return 400 if user does not have facebook link', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const githubUser = githubAuthorisation(newUser.id)\n      await insertAuthorisations([githubUser], config.database)\n      const googleUser = googleAuthorisation(newUser.id)\n      await insertAuthorisations([googleUser], config.database)\n\n      const res = await request(`/v1/auth/facebook/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 if user only has a local login', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n\n      const res = await request(`/v1/auth/facebook/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 200 if user does not have a local login and 2 links', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const facebookUser = facebookAuthorisation(newUser.id)\n      const githubUser = githubAuthorisation(newUser.id)\n      await insertAuthorisations([facebookUser, githubUser], config.database)\n\n      const res = await request(`/v1/auth/facebook/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const oauthFacebookUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.FACEBOOK)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthFacebookUser).toBeUndefined()\n\n      const oauthGithubUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GITHUB)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthGithubUser).toBeDefined()\n    })\n\n    test('should return 403 if unlinking different user', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const res = await request('/v1/auth/facebook/5298', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      const res = await request('/v1/auth/facebook/1234', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/auth/facebook/5298', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/auth/oauth/github.test.ts",
    "content": "import { faker } from '@faker-js/faker'\nimport { env, fetchMock } from 'cloudflare:test'\nimport httpStatus from 'http-status'\nimport { describe, expect, test, beforeAll, afterEach } from 'vitest'\nimport { authProviders } from '../../../../src/config/authProviders'\nimport { getConfig } from '../../../../src/config/config'\nimport { getDBClient } from '../../../../src/config/database'\nimport { tokenTypes } from '../../../../src/config/tokens'\nimport { GithubUserType } from '../../../../src/types/oauth.types'\nimport {\n  appleAuthorisation,\n  githubAuthorisation,\n  googleAuthorisation,\n  insertAuthorisations\n} from '../../../fixtures/authorisations.fixture'\nimport { getAccessToken, TokenResponse } from '../../../fixtures/token.fixture'\nimport { userOne, insertUsers, UserResponse, userTwo } from '../../../fixtures/user.fixture'\nimport { clearDBTables } from '../../../utils/clear-db-tables'\nimport { request } from '../../../utils/test-request'\n\nconst config = getConfig(env)\nconst client = getDBClient(config.database)\n\nclearDBTables(['user', 'authorisations'], config.database)\n\ndescribe('Oauth routes', () => {\n  describe('GET /v1/auth/github/redirect', () => {\n    test('should return 302 and successfully redirect to github', async () => {\n      const state = btoa(JSON.stringify({ platform: 'web' }))\n      const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.platform.web.redirectUrl)\n      const res = await request(`/v1/auth/github/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toBe(\n        'https://github.com/login/oauth/authorize?allow_signup=true&' +\n          `client_id=${config.oauth.provider.github.clientId}&` +\n          `redirect_uri=${urlEncodedRedirectUrl}&scope=read%3Auser%20user%3Aemail&state=${state}`\n      )\n    })\n    test('should return 400 error if state is not provided', async () => {\n      const res = await request('/v1/auth/github/redirect', { method: 'GET' })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if state platform is not provided', async () => {\n      const state = btoa(JSON.stringify({}))\n      const res = await request(`/v1/auth/github/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if state platform is invalid', async () => {\n      const state = btoa(JSON.stringify({ platform: 'fake' }))\n      const res = await request(`/v1/auth/github/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n\n  describe('POST /v1/auth/github/:userId', () => {\n    let newUser: GithubUserType\n    beforeAll(async () => {\n      newUser = {\n        id: faker.number.int(),\n        name: faker.person.fullName(),\n        email: faker.internet.email()\n      }\n      fetchMock.activate()\n    })\n    afterEach(() => fetchMock.assertNoPendingInterceptors())\n    test('should return 200 and successfully link github account', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const githubApiMock = fetchMock.get('https://api.github.com')\n      githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser))\n      const githubMock = fetchMock.get('https://github.com')\n      githubMock\n        .intercept({ method: 'POST', path: '/login/oauth/access_token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/github/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).toBeDefined()\n      expect(dbUser).toMatchObject({\n        name: userOne.name,\n        password: expect.anything(),\n        email: userOne.email,\n        role: userOne.role,\n        is_email_verified: 0\n      })\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GITHUB)\n        .where('authorisations.user_id', '=', userOne.id)\n        .where('authorisations.provider_user_id', '=', String(newUser.id))\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n      if (!oauthUser) return\n    })\n\n    test('should return 401 if user does not exist when linking', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      await client.deleteFrom('user').where('user.id', '=', userOne.id).execute()\n      const githubApiMock = fetchMock.get('https://api.github.com')\n      githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser))\n      const githubMock = fetchMock.get('https://github.com')\n      githubMock\n        .intercept({ method: 'POST', path: '/login/oauth/access_token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/github/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GITHUB)\n        .where('authorisations.user_id', '=', userOne.id)\n        .where('authorisations.provider_user_id', '=', String(newUser.id))\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeUndefined()\n    })\n\n    test('should return 401 if code is invalid', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const githubMock = fetchMock.get('https://github.com')\n      githubMock\n        .intercept({ method: 'POST', path: '/login/oauth/access_token' })\n        .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/github/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 403 if linking different user', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/github/5298', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 400 if no code provided', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const res = await request(`/v1/auth/github/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      const res = await request('/v1/auth/github/1234', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/auth/github/5298', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n    test('should return 400 error if platform is not provided', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const res = await request(`/v1/auth/github/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is invalid', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const res = await request(`/v1/auth/github/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'wb' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n\n  describe('DELETE /v1/auth/github/:userId', () => {\n    test('should return 200 and successfully remove github account link', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const githubUser = githubAuthorisation(userOne.id)\n      await insertAuthorisations([githubUser], config.database)\n\n      const res = await request(`/v1/auth/github/${userOne.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GITHUB)\n        .where('authorisations.user_id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeUndefined()\n      if (!oauthUser) return\n    })\n\n    test('should return 400 if user does not have a local login and only 1 link', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const githubUser = githubAuthorisation(newUser.id)\n      await insertAuthorisations([githubUser], config.database)\n\n      const res = await request(`/v1/auth/github/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GITHUB)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n    })\n\n    test('should return 400 if user does not have github link', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const googleUser = googleAuthorisation(newUser.id)\n      await insertAuthorisations([googleUser], config.database)\n      const appleUser = appleAuthorisation(newUser.id)\n      await insertAuthorisations([appleUser], config.database)\n\n      const res = await request(`/v1/auth/github/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 if user only has a local login', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n\n      const res = await request(`/v1/auth/github/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 200 if user does not have a local login and 2 links', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const githubUser = githubAuthorisation(newUser.id)\n      const appleUser = appleAuthorisation(newUser.id)\n      await insertAuthorisations([githubUser, appleUser], config.database)\n\n      const res = await request(`/v1/auth/github/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const oauthGithubUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GITHUB)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthGithubUser).toBeUndefined()\n\n      const oauthFacebookUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.APPLE)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthFacebookUser).toBeDefined()\n    })\n\n    test('should return 403 if unlinking different user', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const res = await request('/v1/auth/github/5298', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      const res = await request('/v1/auth/github/1234', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/auth/github/5298', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n  })\n\n  describe('POST /v1/auth/github/callback', () => {\n    let newUser: GithubUserType\n    beforeAll(async () => {\n      newUser = {\n        id: faker.number.int(),\n        name: faker.person.fullName(),\n        email: faker.internet.email()\n      }\n      fetchMock.activate()\n    })\n    afterEach(async () => fetchMock.assertNoPendingInterceptors())\n    test('should return 200 and successfully register user if request data is ok', async () => {\n      const githubApiMock = fetchMock.get('https://api.github.com')\n      githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser))\n      const githubMock = fetchMock.get('https://github.com')\n      githubMock\n        .intercept({ method: 'POST', path: '/login/oauth/access_token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/github/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body.user).not.toHaveProperty('password')\n      expect(body.user).toEqual({\n        id: expect.anything(),\n        name: newUser.name,\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 1\n      })\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', body.user.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).toBeNull()\n      expect(dbUser).toMatchObject({\n        name: newUser.name,\n        password: null,\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 1\n      })\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GITHUB)\n        .where('authorisations.user_id', '=', body.user.id)\n        .where('authorisations.provider_user_id', '=', String(newUser.id))\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n      if (!oauthUser) return\n\n      expect(body.tokens).toEqual({\n        access: { token: expect.anything(), expires: expect.anything() },\n        refresh: { token: expect.anything(), expires: expect.anything() }\n      })\n    })\n\n    test('should return 200 and successfully login user if already created', async () => {\n      await insertUsers([userOne], config.database)\n      const githubUser = githubAuthorisation(userOne.id)\n      await insertAuthorisations([githubUser], config.database)\n      newUser.id = parseInt(githubUser.provider_user_id)\n      const githubApiMock = fetchMock.get('https://api.github.com')\n      githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser))\n      const githubMock = fetchMock.get('https://github.com')\n      githubMock\n        .intercept({ method: 'POST', path: '/login/oauth/access_token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/github/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body.user).not.toHaveProperty('password')\n      expect(body.user).toEqual({\n        id: userOne.id,\n        name: userOne.name,\n        email: userOne.email,\n        role: userOne.role,\n        is_email_verified: 0\n      })\n\n      expect(body.tokens).toEqual({\n        access: { token: expect.anything(), expires: expect.anything() },\n        refresh: { token: expect.anything(), expires: expect.anything() }\n      })\n    })\n\n    test('should return 403 if user exists but has not linked their github', async () => {\n      await insertUsers([userOne], config.database)\n      newUser.email = userOne.email\n      const githubApiMock = fetchMock.get('https://api.github.com')\n      githubApiMock.intercept({ method: 'GET', path: '/user' }).reply(200, JSON.stringify(newUser))\n      const githubMock = fetchMock.get('https://github.com')\n      githubMock\n        .intercept({ method: 'POST', path: '/login/oauth/access_token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/github/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n      expect(body).toEqual({\n        code: httpStatus.FORBIDDEN,\n        message: 'Cannot signup with github, user already exists with that email'\n      })\n    })\n\n    test('should return 401 if code is invalid', async () => {\n      const providerId = '123456'\n      const res = await request('/v1/auth/github/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 400 if no code provided', async () => {\n      const res = await request('/v1/auth/github/callback', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is not provided', async () => {\n      const providerId = '123456'\n      const res = await request('/v1/auth/github/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is invalid', async () => {\n      const providerId = '123456'\n      const res = await request('/v1/auth/github/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'wb' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/auth/oauth/google.test.ts",
    "content": "import { faker } from '@faker-js/faker'\nimport { env, fetchMock } from 'cloudflare:test'\nimport httpStatus from 'http-status'\nimport { describe, expect, test, beforeAll, afterEach } from 'vitest'\nimport { authProviders } from '../../../../src/config/authProviders'\nimport { getConfig } from '../../../../src/config/config'\nimport { getDBClient } from '../../../../src/config/database'\nimport { tokenTypes } from '../../../../src/config/tokens'\nimport { GoogleUserType } from '../../../../src/types/oauth.types'\nimport {\n  appleAuthorisation,\n  githubAuthorisation,\n  googleAuthorisation,\n  insertAuthorisations\n} from '../../../fixtures/authorisations.fixture'\nimport { getAccessToken, TokenResponse } from '../../../fixtures/token.fixture'\nimport { userOne, insertUsers, UserResponse, userTwo } from '../../../fixtures/user.fixture'\nimport { clearDBTables } from '../../../utils/clear-db-tables'\nimport { request } from '../../../utils/test-request'\n\nconst config = getConfig(env)\nconst client = getDBClient(config.database)\n\nclearDBTables(['user', 'authorisations'], config.database)\n\ndescribe('Oauth Google routes', () => {\n  describe('GET /v1/auth/google/redirect', () => {\n    test('should return 302 and successfully redirect to google', async () => {\n      const state = btoa(JSON.stringify({ platform: 'web' }))\n      const urlEncodedRedirectUrl = encodeURIComponent(config.oauth.platform.web.redirectUrl)\n      const res = await request(`/v1/auth/google/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toBe(\n        'https://accounts.google.com/o/oauth2/v2/auth?client_id=' +\n          `${config.oauth.provider.google.clientId}&` +\n          `include_granted_scopes=true&redirect_uri=${urlEncodedRedirectUrl}&` +\n          `response_type=code&scope=openid%20email%20profile&state=${state}`\n      )\n    })\n    test('should return 400 error if state is not provided', async () => {\n      const res = await request('/v1/auth/google/redirect', { method: 'GET' })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if state platform is not provided', async () => {\n      const state = btoa(JSON.stringify({}))\n      const res = await request(`/v1/auth/google/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if state platform is invalid', async () => {\n      const state = btoa(JSON.stringify({ platform: 'fake' }))\n      const res = await request(`/v1/auth/google/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n  describe('POST /v1/auth/google/callback', () => {\n    let newUser: GoogleUserType\n    beforeAll(async () => {\n      newUser = {\n        id: faker.number.int().toString(),\n        name: faker.person.fullName(),\n        email: faker.internet.email()\n      }\n      fetchMock.activate()\n    })\n    afterEach(async () => fetchMock.assertNoPendingInterceptors())\n    test('should return 200 and successfully register user if request data is ok', async () => {\n      const googleApiMock = fetchMock.get('https://www.googleapis.com')\n      googleApiMock\n        .intercept({ method: 'GET', path: '/oauth2/v2/userinfo' })\n        .reply(200, JSON.stringify(newUser))\n      const googleMock = fetchMock.get('https://oauth2.googleapis.com')\n      googleMock\n        .intercept({ method: 'POST', path: '/token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/google/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body.user).not.toHaveProperty('password')\n      expect(body.user).toEqual({\n        id: expect.anything(),\n        name: newUser.name,\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 1\n      })\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', body.user.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).toBeNull()\n      expect(dbUser).toMatchObject({\n        name: newUser.name,\n        password: null,\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 1\n      })\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GOOGLE)\n        .where('authorisations.user_id', '=', body.user.id)\n        .where('authorisations.provider_user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n      if (!oauthUser) return\n\n      expect(body.tokens).toEqual({\n        access: { token: expect.anything(), expires: expect.anything() },\n        refresh: { token: expect.anything(), expires: expect.anything() }\n      })\n    })\n\n    test('should return 200 and successfully login user if already created', async () => {\n      await insertUsers([userOne], config.database)\n      const googleUser = googleAuthorisation(userOne.id)\n      await insertAuthorisations([googleUser], config.database)\n      newUser.id = googleUser.provider_user_id\n      const googleApiMock = fetchMock.get('https://www.googleapis.com')\n      googleApiMock\n        .intercept({ method: 'GET', path: '/oauth2/v2/userinfo' })\n        .reply(200, JSON.stringify(newUser))\n      const googleMock = fetchMock.get('https://oauth2.googleapis.com')\n      googleMock\n        .intercept({ method: 'POST', path: '/token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/google/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body.user).not.toHaveProperty('password')\n      expect(body.user).toEqual({\n        id: userOne.id,\n        name: userOne.name,\n        email: userOne.email,\n        role: userOne.role,\n        is_email_verified: 0\n      })\n\n      expect(body.tokens).toEqual({\n        access: { token: expect.anything(), expires: expect.anything() },\n        refresh: { token: expect.anything(), expires: expect.anything() }\n      })\n    })\n\n    test('should return 403 if user exists but has not linked their google', async () => {\n      await insertUsers([userOne], config.database)\n      newUser.email = userOne.email\n      const googleApiMock = fetchMock.get('https://www.googleapis.com')\n      googleApiMock\n        .intercept({ method: 'GET', path: '/oauth2/v2/userinfo' })\n        .reply(200, JSON.stringify(newUser))\n      const googleMock = fetchMock.get('https://oauth2.googleapis.com')\n      googleMock\n        .intercept({ method: 'POST', path: '/token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/google/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n      expect(body).toEqual({\n        code: httpStatus.FORBIDDEN,\n        message: 'Cannot signup with google, user already exists with that email'\n      })\n    })\n\n    test('should return 401 if code is invalid', async () => {\n      const googleMock = fetchMock.get('https://oauth2.googleapis.com')\n      googleMock\n        .intercept({ method: 'POST', path: '/token' })\n        .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/google/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 400 if no code provided', async () => {\n      const res = await request('/v1/auth/google/callback', {\n        method: 'POST',\n        body: JSON.stringify({ platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is not provided', async () => {\n      const providerId = '123456'\n      const res = await request('/v1/auth/google/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is invalid', async () => {\n      const providerId = '123456'\n      const res = await request('/v1/auth/google/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'wb' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n  describe('POST /v1/auth/google/:userId', () => {\n    let newUser: GoogleUserType\n    beforeAll(async () => {\n      newUser = {\n        id: faker.number.int().toString(),\n        name: faker.person.fullName(),\n        email: faker.internet.email()\n      }\n      fetchMock.activate()\n    })\n    afterEach(async () => fetchMock.assertNoPendingInterceptors())\n    test('should return 200 and successfully link google account', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const googleApiMock = fetchMock.get('https://www.googleapis.com')\n      googleApiMock\n        .intercept({ method: 'GET', path: '/oauth2/v2/userinfo' })\n        .reply(200, JSON.stringify(newUser))\n      const googleMock = fetchMock.get('https://oauth2.googleapis.com')\n      googleMock\n        .intercept({ method: 'POST', path: '/token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/google/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).toBeDefined()\n      expect(dbUser).toMatchObject({\n        name: userOne.name,\n        password: expect.anything(),\n        email: userOne.email,\n        role: userOne.role,\n        is_email_verified: 0\n      })\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GOOGLE)\n        .where('authorisations.user_id', '=', userOne.id)\n        .where('authorisations.provider_user_id', '=', newUser.id)\n        .execute()\n\n      expect(oauthUser).toBeDefined()\n      if (!oauthUser) return\n    })\n\n    test('should return 401 if user does not exist when linking', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      await client.deleteFrom('user').where('user.id', '=', userOne.id).execute()\n      const googleApiMock = fetchMock.get('https://www.googleapis.com')\n      googleApiMock\n        .intercept({ method: 'GET', path: '/oauth2/v2/userinfo' })\n        .reply(200, JSON.stringify(newUser))\n      const googleMock = fetchMock.get('https://oauth2.googleapis.com')\n      googleMock\n        .intercept({ method: 'POST', path: '/token' })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/google/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GOOGLE)\n        .where('authorisations.user_id', '=', userOne.id)\n        .where('authorisations.provider_user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeUndefined()\n    })\n\n    test('should return 401 if code is invalid', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const googleMock = fetchMock.get('https://oauth2.googleapis.com')\n      googleMock\n        .intercept({ method: 'POST', path: '/token' })\n        .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))\n\n      const providerId = '123456'\n      const res = await request(`/v1/auth/google/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 403 if linking different user', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/google/5298', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 400 if no code provided', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const res = await request(`/v1/auth/google/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      const res = await request('/v1/auth/google/1234', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/auth/google/5298', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n    test('should return 400 error if platform is not provided', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const res = await request(`/v1/auth/google/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is invalid', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const res = await request(`/v1/auth/google/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'wb' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n\n  describe('DELETE /v1/auth/google/:userId', () => {\n    test('should return 200 and successfully remove google account link', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const googleUser = googleAuthorisation(userOne.id)\n      await insertAuthorisations([googleUser], config.database)\n\n      const res = await request(`/v1/auth/google/${userOne.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GOOGLE)\n        .where('authorisations.user_id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeUndefined()\n      if (!oauthUser) return\n    })\n\n    test('should return 400 if user does not have a local login and only 1 link', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const googleUser = googleAuthorisation(newUser.id)\n      await insertAuthorisations([googleUser], config.database)\n\n      const res = await request(`/v1/auth/google/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GOOGLE)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n    })\n\n    test('should return 400 if user does not have google link', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const githubUser = githubAuthorisation(newUser.id)\n      await insertAuthorisations([githubUser], config.database)\n      const appleUser = appleAuthorisation(newUser.id)\n      await insertAuthorisations([appleUser], config.database)\n\n      const res = await request(`/v1/auth/google/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 if user only has a local login', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userId = newUser.id\n      const userOneAccessToken = await getAccessToken(userId, newUser.role, config.jwt)\n\n      const res = await request(`/v1/auth/google/${userId}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 200 if user does not have a local login and 2 links', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const googleUser = googleAuthorisation(newUser.id)\n      const appleUser = appleAuthorisation(newUser.id)\n      await insertAuthorisations([googleUser, appleUser], config.database)\n\n      const res = await request(`/v1/auth/google/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const oauthGoogleUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.GOOGLE)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthGoogleUser).toBeUndefined()\n\n      const oauthFacebookUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.APPLE)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthFacebookUser).toBeDefined()\n    })\n\n    test('should return 403 if unlinking different user', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const res = await request('/v1/auth/google/5298', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      const res = await request('/v1/auth/google/1234', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/auth/google/5298', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/auth/oauth/spotify.test.ts",
    "content": "import { faker } from '@faker-js/faker'\nimport { env, fetchMock } from 'cloudflare:test'\nimport httpStatus from 'http-status'\nimport { describe, expect, test, beforeAll, afterEach } from 'vitest'\nimport { authProviders } from '../../../../src/config/authProviders'\nimport { getConfig } from '../../../../src/config/config'\nimport { getDBClient } from '../../../../src/config/database'\nimport { tokenTypes } from '../../../../src/config/tokens'\nimport { SpotifyUserType } from '../../../../src/types/oauth.types'\nimport {\n  spotifyAuthorisation,\n  insertAuthorisations,\n  facebookAuthorisation,\n  githubAuthorisation\n} from '../../../fixtures/authorisations.fixture'\nimport { getAccessToken, TokenResponse } from '../../../fixtures/token.fixture'\nimport { userOne, insertUsers, UserResponse, userTwo } from '../../../fixtures/user.fixture'\nimport { clearDBTables } from '../../../utils/clear-db-tables'\nimport { request } from '../../../utils/test-request'\n\nconst config = getConfig(env)\nconst client = getDBClient(config.database)\nconst urlEncodedRedirectUrl = encodeURIComponent(config.oauth.platform.web.redirectUrl)\n\nclearDBTables(['user', 'authorisations'], config.database)\n\ndescribe('Oauth Spotify routes', () => {\n  describe('GET /v1/auth/spotify/redirect', () => {\n    test('should return 302 and successfully redirect to spotify', async () => {\n      const state = btoa(JSON.stringify({ platform: 'web' }))\n      const res = await request(`/v1/auth/spotify/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.FOUND)\n      expect(res.headers.get('location')).toBe(\n        'https://accounts.spotify.com/authorize?client_id=' +\n          `${config.oauth.provider.spotify.clientId}&` +\n          `redirect_uri=${urlEncodedRedirectUrl}&response_type=code&` +\n          `scope=user-read-email&show_dialog=false&state=${state}`\n      )\n    })\n    test('should return 400 error if state is not provided', async () => {\n      const res = await request('/v1/auth/spotify/redirect', { method: 'GET' })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if state platform is not provided', async () => {\n      const state = btoa(JSON.stringify({}))\n      const res = await request(`/v1/auth/spotify/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if state platform is invalid', async () => {\n      const state = btoa(JSON.stringify({ platform: 'fake' }))\n      const res = await request(`/v1/auth/spotify/redirect?state=${state}`, {\n        method: 'GET'\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n\n  describe('POST /v1/auth/spotify/callback', () => {\n    let newUser: SpotifyUserType\n    beforeAll(async () => {\n      newUser = {\n        id: faker.number.int().toString(),\n        display_name: faker.person.fullName(),\n        email: faker.internet.email()\n      }\n      fetchMock.activate()\n    })\n    afterEach(() => fetchMock.assertNoPendingInterceptors())\n    test('should return 200 and successfully register user if request data is ok', async () => {\n      const providerId = '123456'\n      const spotifyApiMock = fetchMock.get('https://api.spotify.com')\n      spotifyApiMock\n        .intercept({ method: 'GET', path: '/v1/me' })\n        .reply(200, JSON.stringify(newUser))\n      const spotifyMock = fetchMock.get('https://accounts.spotify.com')\n      spotifyMock\n        .intercept({\n          method: 'POST',\n          path:\n            `/api/token?code=${providerId}&grant_type=authorization_code&` +\n            `redirect_uri=${urlEncodedRedirectUrl}`\n        })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const res = await request('/v1/auth/spotify/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body.user).not.toHaveProperty('password')\n      expect(body.user).toEqual({\n        id: expect.anything(),\n        name: newUser.display_name,\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 1\n      })\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', body.user.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).toBeNull()\n      expect(dbUser).toMatchObject({\n        name: newUser.display_name,\n        password: null,\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 1\n      })\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.SPOTIFY)\n        .where('authorisations.user_id', '=', body.user.id)\n        .where('authorisations.provider_user_id', '=', String(newUser.id))\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n      if (!oauthUser) return\n\n      expect(body.tokens).toEqual({\n        access: { token: expect.anything(), expires: expect.anything() },\n        refresh: { token: expect.anything(), expires: expect.anything() }\n      })\n    })\n\n    test('should return 200 and successfully login user if already created', async () => {\n      await insertUsers([userOne], config.database)\n      const spotifyUser = spotifyAuthorisation(userOne.id)\n      await insertAuthorisations([spotifyUser], config.database)\n      newUser.id = spotifyUser.provider_user_id\n      const providerId = '123456'\n      const spotifyApiMock = fetchMock.get('https://api.spotify.com')\n      spotifyApiMock\n        .intercept({ method: 'GET', path: '/v1/me' })\n        .reply(200, JSON.stringify(newUser))\n      const spotifyMock = fetchMock.get('https://accounts.spotify.com')\n      spotifyMock\n        .intercept({\n          method: 'POST',\n          path:\n            `/api/token?code=${providerId}&grant_type=authorization_code&` +\n            `redirect_uri=${urlEncodedRedirectUrl}`\n        })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const res = await request('/v1/auth/spotify/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body.user).not.toHaveProperty('password')\n      expect(body.user).toEqual({\n        id: userOne.id,\n        name: userOne.name,\n        email: userOne.email,\n        role: userOne.role,\n        is_email_verified: 0\n      })\n\n      expect(body.tokens).toEqual({\n        access: { token: expect.anything(), expires: expect.anything() },\n        refresh: { token: expect.anything(), expires: expect.anything() }\n      })\n    })\n\n    test('should return 403 if user exists but has not linked their spotify', async () => {\n      await insertUsers([userOne], config.database)\n      newUser.email = userOne.email\n      const providerId = '123456'\n      const spotifyApiMock = fetchMock.get('https://api.spotify.com')\n      spotifyApiMock\n        .intercept({ method: 'GET', path: '/v1/me' })\n        .reply(200, JSON.stringify(newUser))\n      const spotifyMock = fetchMock.get('https://accounts.spotify.com')\n      spotifyMock\n        .intercept({\n          method: 'POST',\n          path:\n            `/api/token?code=${providerId}&grant_type=authorization_code&` +\n            `redirect_uri=${urlEncodedRedirectUrl}`\n        })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const res = await request('/v1/auth/spotify/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      const body = await res.json<{ user: UserResponse; tokens: TokenResponse }>()\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n      expect(body).toEqual({\n        code: httpStatus.FORBIDDEN,\n        message: 'Cannot signup with spotify, user already exists with that email'\n      })\n    })\n\n    test('should return 401 if code is invalid', async () => {\n      const providerId = '123456'\n      const spotifyMock = fetchMock.get('https://accounts.spotify.com')\n      spotifyMock\n        .intercept({\n          method: 'POST',\n          path:\n            `/api/token?code=${providerId}&grant_type=authorization_code&` +\n            `redirect_uri=${urlEncodedRedirectUrl}`\n        })\n        .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))\n\n      const res = await request('/v1/auth/spotify/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 400 if no code provided', async () => {\n      const res = await request('/v1/auth/spotify/callback', {\n        method: 'POST',\n        body: JSON.stringify({ platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is not provided', async () => {\n      const providerId = '123456'\n      const res = await request('/v1/auth/spotify/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is invalid', async () => {\n      const providerId = '123456'\n      const res = await request('/v1/auth/spotify/callback', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'wb' }),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n  describe('POST /v1/auth/spotify/:userId', () => {\n    let newUser: SpotifyUserType\n    beforeAll(async () => {\n      newUser = {\n        id: faker.number.int().toString(),\n        display_name: faker.person.fullName(),\n        email: faker.internet.email()\n      }\n      fetchMock.activate()\n    })\n    afterEach(() => fetchMock.assertNoPendingInterceptors())\n    test('should return 200 and successfully link spotify account', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const spotifyApiMock = fetchMock.get('https://api.spotify.com')\n      spotifyApiMock\n        .intercept({ method: 'GET', path: '/v1/me' })\n        .reply(200, JSON.stringify(newUser))\n      const spotifyMock = fetchMock.get('https://accounts.spotify.com')\n      spotifyMock\n        .intercept({\n          method: 'POST',\n          path:\n            `/api/token?code=${providerId}&grant_type=authorization_code&` +\n            `redirect_uri=${urlEncodedRedirectUrl}`\n        })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const res = await request(`/v1/auth/spotify/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).toBeDefined()\n      expect(dbUser).toMatchObject({\n        name: userOne.name,\n        password: expect.anything(),\n        email: userOne.email,\n        role: userOne.role,\n        is_email_verified: 0\n      })\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.SPOTIFY)\n        .where('authorisations.user_id', '=', userOne.id)\n        .where('authorisations.provider_user_id', '=', String(newUser.id))\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n      if (!oauthUser) return\n    })\n\n    test('should return 401 if user does not exist when linking', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      await client.deleteFrom('user').where('user.id', '=', userOne.id).execute()\n      const providerId = '123456'\n      const spotifyApiMock = fetchMock.get('https://api.spotify.com')\n      spotifyApiMock\n        .intercept({ method: 'GET', path: '/v1/me' })\n        .reply(200, JSON.stringify(newUser))\n      const spotifyMock = fetchMock.get('https://accounts.spotify.com')\n      spotifyMock\n        .intercept({\n          method: 'POST',\n          path:\n            `/api/token?code=${providerId}&grant_type=authorization_code&` +\n            `redirect_uri=${urlEncodedRedirectUrl}`\n        })\n        .reply(200, JSON.stringify({ access_token: '1234' }))\n\n      const res = await request(`/v1/auth/spotify/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.SPOTIFY)\n        .where('authorisations.user_id', '=', userOne.id)\n        .where('authorisations.provider_user_id', '=', String(newUser.id))\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeUndefined()\n    })\n\n    test('should return 401 if code is invalid', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const spotifyMock = fetchMock.get('https://accounts.spotify.com')\n      spotifyMock\n        .intercept({\n          method: 'POST',\n          path:\n            `/api/token?code=${providerId}&grant_type=authorization_code&` +\n            `redirect_uri=${urlEncodedRedirectUrl}`\n        })\n        .reply(httpStatus.UNAUTHORIZED, JSON.stringify({ error: 'error' }))\n\n      const res = await request(`/v1/auth/spotify/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 403 if linking different user', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const providerId = '123456'\n      const res = await request('/v1/auth/spotify/5298', {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 400 if no code provided', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const res = await request(`/v1/auth/spotify/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ platform: 'web' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      const res = await request('/v1/auth/spotify/1234', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/auth/spotify/5298', {\n        method: 'POST',\n        body: JSON.stringify({}),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n    test('should return 400 error if platform is not provided', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const res = await request(`/v1/auth/spotify/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 400 error if platform is invalid', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const providerId = '123456'\n      const res = await request(`/v1/auth/spotify/${userOne.id}`, {\n        method: 'POST',\n        body: JSON.stringify({ code: providerId, platform: 'wb' }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n\n  describe('DELETE /v1/auth/spotify/:userId', () => {\n    test('should return 200 and successfully remove spotify account link', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const spotifyUser = spotifyAuthorisation(userOne.id)\n      await insertAuthorisations([spotifyUser], config.database)\n\n      const res = await request(`/v1/auth/spotify/${userOne.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.SPOTIFY)\n        .where('authorisations.user_id', '=', userOne.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeUndefined()\n      if (!oauthUser) return\n    })\n\n    test('should return 400 if user does not have a local login and only 1 link', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const spotifyUser = spotifyAuthorisation(newUser.id)\n      await insertAuthorisations([spotifyUser], config.database)\n\n      const res = await request(`/v1/auth/spotify/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n\n      const oauthUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.SPOTIFY)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthUser).toBeDefined()\n    })\n\n    test('should return 400 if user only has a local login', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n\n      const res = await request(`/v1/auth/spotify/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 if user does not have spotify link', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const githubUser = githubAuthorisation(newUser.id)\n      await insertAuthorisations([githubUser], config.database)\n      const facebookUser = facebookAuthorisation(newUser.id)\n      await insertAuthorisations([facebookUser], config.database)\n\n      const res = await request(`/v1/auth/spotify/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 200 if user does not have a local login and 2 links', async () => {\n      const newUser = { ...userOne, password: null }\n      await insertUsers([newUser], config.database)\n      const userOneAccessToken = await getAccessToken(newUser.id, newUser.role, config.jwt)\n      const spotifyUser = spotifyAuthorisation(newUser.id)\n      const facebookUser = facebookAuthorisation(newUser.id)\n      await insertAuthorisations([spotifyUser, facebookUser], config.database)\n\n      const res = await request(`/v1/auth/spotify/${newUser.id}`, {\n        method: 'DELETE',\n        headers: {\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n\n      const oauthSpotifyUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.SPOTIFY)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthSpotifyUser).toBeUndefined()\n\n      const oauthFacebookUser = await client\n        .selectFrom('authorisations')\n        .selectAll()\n        .where('authorisations.provider_type', '=', authProviders.FACEBOOK)\n        .where('authorisations.user_id', '=', newUser.id)\n        .executeTakeFirst()\n\n      expect(oauthFacebookUser).toBeDefined()\n    })\n\n    test('should return 403 if unlinking different user', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const res = await request('/v1/auth/spotify/5298', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      const res = await request('/v1/auth/spotify/1234', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/auth/spotify/5298', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/index.test.ts",
    "content": "import httpStatus from 'http-status'\nimport { test, describe, expect } from 'vitest'\nimport { request } from '../utils/test-request'\n\ndescribe('Basic routing', () => {\n  test('should return 404 if route not found', async () => {\n    const res = await request('/idontexist', {\n      method: 'GET'\n    })\n    expect(res.status).toBe(httpStatus.NOT_FOUND)\n  })\n})\n"
  },
  {
    "path": "tests/integration/rate-limiter.test.ts",
    "content": "import { env, runInDurableObject, runDurableObjectAlarm } from 'cloudflare:test'\nimport dayjs from 'dayjs'\nimport isSameOrBefore from 'dayjs/plugin/isSameOrBefore'\nimport httpStatus from 'http-status'\nimport MockDate from 'mockdate'\nimport { test, describe, expect, beforeEach } from 'vitest'\nimport { RateLimiter } from '../../src'\n\ndayjs.extend(isSameOrBefore)\n\nconst key = '127.0.0.1'\nconst id = env.RATE_LIMITER.idFromName(key)\nconst fakeDomain = 'http://iamaratelimiter.com/'\n\ndescribe('Durable Object RateLimiter', () => {\n  describe('Fetch /', () => {\n    beforeEach(async () => {\n      const stub = env.RATE_LIMITER.get(id)\n      await runInDurableObject(stub, async (_, state) => {\n        await state.storage.deleteAll()\n      })\n      MockDate.reset()\n    })\n    test('should return 200 and not rate limit if limit not hit', async () => {\n      const config = {\n        scope: '/v1/auth/send-verification-email',\n        key,\n        limit: 1,\n        interval: 60\n      }\n      const rateLimiter = env.RATE_LIMITER.get(id)\n      const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const body = await res.json()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toEqual({ blocked: false, remaining: 0, expires: expect.any(String) })\n    })\n\n    test('should return 200 and rate limit if limit hit', async () => {\n      const config = {\n        scope: '/v1/auth/send-verification-email',\n        key,\n        limit: 200,\n        interval: 600\n      }\n      const currentWindow = Math.floor(dayjs().unix() / config.interval)\n      const storageKey =\n        `${config.scope}|${config.key.toString()}|${config.limit}|` +\n        `${config.interval}|${currentWindow}`\n      const rateLimiter = env.RATE_LIMITER.get(id)\n      await runInDurableObject(rateLimiter, async (_, state) => {\n        await state.storage.put(storageKey, config.limit + 1)\n      })\n      const start = dayjs()\n      const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const body = await res.json()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) })\n\n      const expires = dayjs(res.headers.get('expires'))\n      expect(start.isSameOrBefore(expires)).toBe(true)\n\n      const cacheControl = res.headers.get('cache-control')\n      expect(cacheControl).toBeDefined()\n    })\n\n    test('should return 200 and not rate limit if different endpoint hit', async () => {\n      const config = {\n        scope: '/v1/auth/send-verification-email',\n        key,\n        limit: 200,\n        interval: 600\n      }\n      const currentWindow = Math.floor(dayjs().unix() / config.interval)\n      const storageKey =\n        `${config.scope}|${config.key.toString()}|${config.limit}|` +\n        `${config.interval}|${currentWindow}`\n      const rateLimiter = env.RATE_LIMITER.get(id)\n      await runInDurableObject(rateLimiter, async (_, state) => {\n        await state.storage.put(storageKey, config.limit + 1)\n      })\n      const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const body = await res.json()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) })\n\n      config.scope = '/v1/different-endpoint'\n      const res2 = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const body2 = await res2.json()\n      expect(res2.status).toBe(httpStatus.OK)\n      expect(body2).toEqual({ blocked: false, remaining: 199, expires: expect.any(String) })\n    })\n\n    test('should return 200 and not rate limit if different key used', async () => {\n      const config = {\n        scope: '/v1/auth/send-verification-email',\n        key,\n        limit: 200,\n        interval: 600\n      }\n      const currentWindow = Math.floor(dayjs().unix() / config.interval)\n      const storageKey =\n        `${config.scope}|${config.key.toString()}|${config.limit}|` +\n        `${config.interval}|${currentWindow}`\n      const rateLimiter = env.RATE_LIMITER.get(id)\n      await runInDurableObject(rateLimiter, async (_, state) => {\n        await state.storage.put(storageKey, config.limit + 1)\n      })\n      const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const body = await res.json()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) })\n      config.key = '192.169.2.1'\n      const res2 = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const body2 = await res2.json()\n      expect(res2.status).toBe(httpStatus.OK)\n      expect(body2).toEqual({ blocked: false, remaining: 199, expires: expect.any(String) })\n    })\n\n    test('should return 200 and not rate limit if window expired', async () => {\n      const config = {\n        scope: '/v1/auth/send-verification-email',\n        key,\n        limit: 200,\n        interval: 600\n      }\n      const currentWindow = Math.floor(dayjs().unix() / config.interval)\n      const storageKey =\n        `${config.scope}|${config.key.toString()}|${config.limit}|` +\n        `${config.interval}|${currentWindow}`\n      const rateLimiter = env.RATE_LIMITER.get(id)\n      await runInDurableObject(rateLimiter, async (_, state) => {\n        await state.storage.put(storageKey, config.limit)\n      })\n      const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const body = await res.json()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) })\n      const expires = dayjs(res.headers.get('expires'))\n      MockDate.set(expires.add(1, 'second').toDate())\n      const res2 = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const body2 = await res2.json()\n      expect(res2.status).toBe(httpStatus.OK)\n      expect(body2).toEqual({ blocked: false, remaining: 0, expires: expect.any(String) })\n    })\n\n    test('should return 200 and rate limit if just before window expiry', async () => {\n      const config = {\n        scope: '/v1/auth/send-verification-email',\n        key,\n        limit: 200,\n        interval: 600\n      }\n      const currentWindow = Math.floor(dayjs().unix() / config.interval)\n      const storageKey =\n        `${config.scope}|${config.key.toString()}|${config.limit}|` +\n        `${config.interval}|${currentWindow}`\n      const rateLimiter = env.RATE_LIMITER.get(id)\n      await runInDurableObject(rateLimiter, async (_, state) => {\n        await state.storage.put(storageKey, config.limit + 1)\n      })\n      const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const body = await res.json()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) })\n\n      const expires = dayjs(res.headers.get('expires')).subtract(1, 'second')\n      MockDate.set(expires.toDate())\n\n      const res2 = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const body2 = await res2.json()\n      expect(res2.status).toBe(httpStatus.OK)\n      expect(body2).toEqual({ blocked: true, remaining: 0, expires: expect.any(String) })\n    })\n\n    test('should return 400 if config is invalid', async () => {\n      const config = {\n        key,\n        limit: 1,\n        interval: 60\n      }\n      expect(true).toBe(true)\n      const rateLimiter = env.RATE_LIMITER.get(id)\n      const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 if limit is not an integer', async () => {\n      const config = {\n        scope: '/v1/auth/send-verification-email',\n        key,\n        limit: 'hi',\n        interval: 60\n      }\n      const rateLimiter = env.RATE_LIMITER.get(id)\n      const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 if interval is not an integer', async () => {\n      const config = {\n        scope: '/v1/auth/send-verification-email',\n        key,\n        limit: 1,\n        interval: 'hiiam interval'\n      }\n      const rateLimiter = env.RATE_LIMITER.get(id)\n      const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n  })\n\n  describe('Alarm', () => {\n    beforeEach(async () => {\n      MockDate.reset()\n    })\n\n    test('should expire key after 2 intervals have passed', async () => {\n      const doConfig = {\n        scope: '/v1/auth/send-verification-email',\n        key,\n        limit: 1,\n        interval: 60\n      }\n      const rateLimiter = env.RATE_LIMITER.get(id)\n      const currentWindow = Math.floor(dayjs().unix() / doConfig.interval)\n      const storageKey =\n        `${doConfig.scope}|${doConfig.key.toString()}|${doConfig.limit}|` +\n        `${doConfig.interval}|${currentWindow}`\n\n      const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(doConfig)\n        })\n        return await instance.fetch(res)\n      })\n      const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629\n      expect(res.status).toBe(httpStatus.OK)\n      const values = await runInDurableObject(rateLimiter, async (_, state) => {\n        return await state.storage.list()\n      })\n      expect(values.size).toBe(1)\n      expect(values.get(storageKey)).toBe(1)\n\n      MockDate.set(\n        dayjs()\n          .add(doConfig.interval * 3, 'seconds')\n          .toDate()\n      )\n      await runInDurableObject(rateLimiter, async (_, state) => {\n        await state.storage.put(storageKey, doConfig.limit + 1)\n      })\n      await runDurableObjectAlarm(rateLimiter)\n      const values2 = await runInDurableObject(rateLimiter, async (_, state) => {\n        return await state.storage.list()\n      })\n      expect(values2.size).toBe(0)\n    })\n\n    test('should not expire key if within 2 intervals', async () => {\n      const config = {\n        scope: '/v1/auth/send-verification-email',\n        key,\n        limit: 1,\n        interval: 60\n      }\n      const rateLimiter = env.RATE_LIMITER.get(id)\n      const currentWindow = Math.floor(dayjs().unix() / config.interval)\n      const storageKey =\n        `${config.scope}|${config.key.toString()}|${config.limit}|` +\n        `${config.interval}|${currentWindow}`\n\n      const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629\n      expect(res.status).toBe(httpStatus.OK)\n      const values = await runInDurableObject(rateLimiter, async (_, state) => {\n        return await state.storage.list()\n      })\n      expect(values.size).toBe(1)\n      expect(values.get(storageKey)).toBe(1)\n\n      MockDate.set(\n        dayjs()\n          .add(config.interval * 1.5, 'seconds')\n          .toDate()\n      )\n      await runDurableObjectAlarm(rateLimiter)\n      const values2 = await runInDurableObject(rateLimiter, async (_, state) => {\n        return await state.storage.list()\n      })\n      expect(values2.size).toBe(1)\n      expect(values2.get(storageKey)).toBe(1)\n    })\n\n    test('should expire keys that are more than 2 intervals old and keep the others', async () => {\n      const config = {\n        scope: '/v1/auth/send-verification-email',\n        key,\n        limit: 1,\n        interval: 60\n      }\n      const rateLimiter = env.RATE_LIMITER.get(id)\n\n      const currentWindow = Math.floor(dayjs().unix() / config.interval)\n      const storageKey =\n        `${config.scope}|${config.key.toString()}|${config.limit}|` +\n        `${config.interval}|${currentWindow}`\n\n      const res = await runInDurableObject(rateLimiter, async (instance: RateLimiter, _) => {\n        const res = new Request(fakeDomain, {\n          method: 'POST',\n          body: JSON.stringify(config)\n        })\n        return await instance.fetch(res)\n      })\n      const _ = await res.json() // https://github.com/cloudflare/workers-sdk/issues/5629\n      expect(res.status).toBe(httpStatus.OK)\n\n      const expiredWindow = Math.floor(dayjs().unix() / config.interval - 3)\n      const expiredStorageKey =\n        `${config.scope}|${config.key.toString()}|${config.limit}|` +\n        `${config.interval}|${expiredWindow}`\n\n      await runInDurableObject(rateLimiter, async (_, state) => {\n        await state.storage.put(expiredStorageKey, 45)\n      })\n\n      const expiredWindow2 = Math.floor(dayjs().unix() / config.interval - 7)\n      const expiredStorageKey2 =\n        `${config.scope}|${config.key.toString()}|${config.limit}|` +\n        `${config.interval}|${expiredWindow2}`\n\n      await runInDurableObject(rateLimiter, async (_, state) => {\n        await state.storage.put(expiredStorageKey2, 33)\n      })\n\n      const expiredWindow3 = Math.floor(dayjs().unix() / config.interval - 4)\n      const expiredStorageKey3 =\n        `${config.scope}|${config.key.toString()}|${config.limit}|` +\n        `${config.interval}|${expiredWindow3}`\n\n      await runInDurableObject(rateLimiter, async (_, state) => {\n        await state.storage.put(expiredStorageKey3, 12)\n      })\n\n      const window2 = Math.floor(dayjs().unix() / config.interval - 1.5)\n      const storageKey2 =\n        `${config.scope}|${config.key.toString()}|${config.limit}|` +\n        `${config.interval}|${window2}`\n\n      await runInDurableObject(rateLimiter, async (_, state) => {\n        await state.storage.put(storageKey2, 12)\n      })\n\n      const values = await runInDurableObject(rateLimiter, async (_, state) => {\n        return await state.storage.list()\n      })\n      expect(values.size).toBe(5)\n\n      await runDurableObjectAlarm(rateLimiter)\n\n      const values2 = await runInDurableObject(rateLimiter, async (_, state) => {\n        return await state.storage.list()\n      })\n      expect(values2.size).toBe(2)\n      expect(values2.get(expiredStorageKey)).toBeUndefined()\n      expect(values2.get(expiredStorageKey2)).toBeUndefined()\n      expect(values2.get(expiredStorageKey3)).toBeUndefined()\n      expect(values2.get(storageKey)).toBe(1)\n      expect(values2.get(storageKey2)).toBe(12)\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/user.test.ts",
    "content": "import { faker } from '@faker-js/faker'\nimport { env } from 'cloudflare:test'\nimport httpStatus from 'http-status'\nimport { test, describe, expect, beforeEach } from 'vitest'\nimport { getConfig } from '../../src/config/config'\nimport { getDBClient } from '../../src/config/database'\nimport { tokenTypes } from '../../src/config/tokens'\nimport { CreateUser } from '../../src/validations/user.validation'\nimport { getAccessToken } from '../fixtures/token.fixture'\nimport { UserResponse } from '../fixtures/user.fixture'\nimport { userOne, userTwo, admin, insertUsers } from '../fixtures/user.fixture'\nimport { clearDBTables } from '../utils/clear-db-tables'\nimport { request } from '../utils/test-request'\n\nconst config = getConfig(env)\nconst client = getDBClient(config.database)\n\nclearDBTables(['user'], config.database)\n\ndescribe('User routes', () => {\n  describe('POST /v1/users', () => {\n    let newUser: CreateUser\n\n    beforeEach(() => {\n      newUser = {\n        name: faker.person.fullName(),\n        email: faker.internet.email().toLowerCase(),\n        password: 'password1',\n        role: 'user',\n        is_email_verified: false\n      }\n    })\n\n    test('should return 201 and successfully create new user if data is ok', async () => {\n      await insertUsers([admin], config.database)\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request('/v1/users', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      const body = await res.json<UserResponse>()\n      expect(res.status).toBe(httpStatus.CREATED)\n      expect(body).not.toHaveProperty('password')\n      expect(body).toEqual({\n        id: expect.any(String),\n        name: newUser.name,\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 0\n      })\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', body.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).not.toBe(newUser.password)\n      expect(dbUser).toEqual({\n        id: body.id,\n        name: newUser.name,\n        password: expect.anything(),\n        email: newUser.email,\n        role: 'user',\n        is_email_verified: 0,\n        created_at: expect.any(Date),\n        updated_at: expect.any(Date)\n      })\n    })\n\n    test('should be able to create an admin as well', async () => {\n      await insertUsers([admin], config.database)\n      newUser.role = 'admin'\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request('/v1/users', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      const body = await res.json<UserResponse>()\n      expect(res.status).toBe(httpStatus.CREATED)\n      expect(body.role).toBe('admin')\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', body.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).not.toBe(newUser.password)\n      expect(dbUser.role).toBe('admin')\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      const res = await request('/v1/users', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 403 error if logged in user is not admin', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n\n      const res = await request('/v1/users', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 400 error if email is invalid', async () => {\n      await insertUsers([admin], config.database)\n      newUser.email = 'invalidEmail'\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request('/v1/users', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 error if email is already used', async () => {\n      await insertUsers([admin, userOne], config.database)\n      newUser.email = userOne.email\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request('/v1/users', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 error if password length is less than 8 characters', async () => {\n      await insertUsers([admin], config.database)\n      newUser.password = 'passwo1'\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request('/v1/users', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 if password does not contain both letters and numbers', async () => {\n      await insertUsers([admin], config.database)\n      newUser.password = 'password'\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request('/v1/users', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n\n      newUser.password = '1111111'\n\n      const res2 = await request('/v1/users', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res2.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 error if role is neither user nor admin', async () => {\n      await insertUsers([admin], config.database)\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request('/v1/users', {\n        method: 'POST',\n        body: JSON.stringify({\n          ...newUser,\n          role: 'invalid'\n        }),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 201 and is_email_verified false if set to true', async () => {\n      await insertUsers([admin], config.database)\n      newUser.is_email_verified = true\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request('/v1/users', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.CREATED)\n      const body = await res.json<UserResponse>()\n      expect(body.is_email_verified).toBe(0)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/users', {\n        method: 'POST',\n        body: JSON.stringify(newUser),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n  })\n\n  describe('GET /v1/users', () => {\n    test('should return 200 and apply the default query options', async () => {\n      await insertUsers([userOne, userTwo, admin], config.database)\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request('/v1/users', {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      const body = await res.json<UserResponse[]>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toHaveLength(3)\n      expect(body).toEqual(\n        expect.arrayContaining([\n          {\n            id: userOne.id,\n            name: userOne.name,\n            email: userOne.email,\n            role: userOne.role,\n            is_email_verified: 0\n          },\n          {\n            id: userTwo.id,\n            name: userTwo.name,\n            email: userTwo.email,\n            role: userTwo.role,\n            is_email_verified: 0\n          },\n          {\n            id: admin.id,\n            name: admin.name,\n            email: admin.email,\n            role: admin.role,\n            is_email_verified: 0\n          }\n        ])\n      )\n    })\n\n    test('should return 401 if access token is missing', async () => {\n      await insertUsers([userOne, userTwo, admin], config.database)\n      const res = await request('/v1/users', {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 403 if a non-admin is trying to access all users', async () => {\n      await insertUsers([userOne, userTwo, admin], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const res = await request('/v1/users', {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should correctly apply filter on email field', async () => {\n      await insertUsers([userOne, userTwo, admin], config.database)\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request(`/v1/users?email=${userOne.email}`, {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      const body = await res.json<UserResponse[]>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toHaveLength(1)\n      expect(body[0].id).toBe(userOne.id)\n    })\n\n    test('should limit returned array if limit param is specified', async () => {\n      await insertUsers([userOne, userTwo, admin], config.database)\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request('/v1/users?limit=2', {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      const body = await res.json<UserResponse[]>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toHaveLength(2)\n    })\n\n    test('should return the correct page if page and limit params are specified', async () => {\n      await insertUsers([userOne, userTwo, admin], config.database)\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request('/v1/users?limit=2&page=1', {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      const body = await res.json<UserResponse[]>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).toHaveLength(1)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/users?limit=2&page=1', {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n  })\n\n  describe('GET /v1/users/:userId', () => {\n    test('should return 200 and the user object if data is ok', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const res = await request(`/v1/users/${userOne.id}`, {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      const body = await res.json<UserResponse[]>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).not.toHaveProperty('password')\n      expect(body).toEqual({\n        id: userOne.id,\n        name: userOne.name,\n        email: userOne.email,\n        role: userOne.role,\n        is_email_verified: 0\n      })\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      await insertUsers([userOne], config.database)\n      const res = await request(`/v1/users/${userOne.id}`, {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 403 error if user is trying to get another user', async () => {\n      await insertUsers([userOne, userTwo], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const res = await request(`/v1/users/${userTwo.id}`, {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 200 and user if admin is trying to get another user', async () => {\n      await insertUsers([userOne, admin], config.database)\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request(`/v1/users/${userOne.id}`, {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.OK)\n    })\n\n    test('should return 404 error if user is not found', async () => {\n      await insertUsers([admin], config.database)\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request('/v1/users/1221212', {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NOT_FOUND)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/users/1221212', {\n        method: 'GET',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n  })\n\n  describe('DELETE /v1/users/:userId', () => {\n    test('should return 204 if data is ok', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const res = await request(`/v1/users/${userOne.id}`, {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', userOne.id)\n        .executeTakeFirst()\n      expect(dbUser).toBe(undefined)\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      await insertUsers([userOne], config.database)\n      const res = await request(`/v1/users/${userOne.id}`, {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 403 error if user is trying to delete another user', async () => {\n      await insertUsers([userOne, userTwo], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const res = await request(`/v1/users/${userTwo.id}`, {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 204 if admin is trying to delete another user', async () => {\n      await insertUsers([userOne, admin], config.database)\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request(`/v1/users/${userOne.id}`, {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NO_CONTENT)\n    })\n\n    test('should return 404 error if user already is not found', async () => {\n      await insertUsers([admin], config.database)\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const res = await request('/v1/users/12345', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NOT_FOUND)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const res = await request('/v1/users/12345', {\n        method: 'DELETE',\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n  })\n\n  describe('PATCH /v1/users/:userId', () => {\n    test('should return 200 and successfully update user if data is ok', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const updateBody = {\n        name: faker.person.fullName(),\n        email: faker.internet.email().toLowerCase()\n      }\n\n      const res = await request(`/v1/users/${userOne.id}`, {\n        method: 'PATCH',\n        body: JSON.stringify(updateBody),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      const body = await res.json<UserResponse>()\n      expect(res.status).toBe(httpStatus.OK)\n      expect(body).not.toHaveProperty('password')\n      expect(body).toEqual({\n        id: userOne.id,\n        name: updateBody.name,\n        email: updateBody.email,\n        role: 'user',\n        is_email_verified: 0\n      })\n\n      const dbUser = await client\n        .selectFrom('user')\n        .selectAll()\n        .where('user.id', '=', body.id)\n        .executeTakeFirst()\n\n      expect(dbUser).toBeDefined()\n      if (!dbUser) return\n\n      expect(dbUser.password).not.toBe(userOne.password)\n      expect(dbUser).toMatchObject({\n        name: updateBody.name,\n        password: expect.anything(),\n        email: updateBody.email,\n        role: 'user',\n        is_email_verified: 0\n      })\n    })\n\n    test('should return 401 error if access token is missing', async () => {\n      await insertUsers([userOne], config.database)\n      const updateBody = { name: faker.person.fullName() }\n      const res = await request(`/v1/users/${userOne.id}`, {\n        method: 'PATCH',\n        body: JSON.stringify(updateBody),\n        headers: {\n          'Content-Type': 'application/json'\n        }\n      })\n      expect(res.status).toBe(httpStatus.UNAUTHORIZED)\n    })\n\n    test('should return 403 if user is updating another user', async () => {\n      await insertUsers([userOne, userTwo], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const updateBody = { name: faker.person.fullName() }\n      const res = await request(`/v1/users/${userTwo.id}`, {\n        method: 'PATCH',\n        body: JSON.stringify(updateBody),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n\n    test('should return 200 and update user if admin is updating another user', async () => {\n      await insertUsers([userOne, admin], config.database)\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const updateBody = { name: faker.person.fullName() }\n      const res = await request(`/v1/users/${userOne.id}`, {\n        method: 'PATCH',\n        body: JSON.stringify(updateBody),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.OK)\n    })\n\n    test('should return 404 if admin is updating another user that is not found', async () => {\n      await insertUsers([admin], config.database)\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const updateBody = { name: faker.person.fullName() }\n      const res = await request('/v1/users/123123222', {\n        method: 'PATCH',\n        body: JSON.stringify(updateBody),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.NOT_FOUND)\n    })\n\n    test('should return 400 if email is invalid', async () => {\n      await insertUsers([userOne, admin], config.database)\n      const adminAccessToken = await getAccessToken(admin.id, admin.role, config.jwt)\n      const updateBody = { email: 'invalidEmail' }\n      const res = await request(`/v1/users/${userOne.id}`, {\n        method: 'PATCH',\n        body: JSON.stringify(updateBody),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${adminAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should return 400 if email is already taken', async () => {\n      await insertUsers([userOne, userTwo], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const updateBody = { email: userTwo.email }\n      const res = await request(`/v1/users/${userOne.id}`, {\n        method: 'PATCH',\n        body: JSON.stringify(updateBody),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n\n    test('should not return 400 if email is my email', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const updateBody = { email: userOne.email }\n      const res = await request(`/v1/users/${userOne.id}`, {\n        method: 'PATCH',\n        body: JSON.stringify(updateBody),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.OK)\n    })\n    test('should return 400 if one of email/password/role are not passed in', async () => {\n      await insertUsers([userOne], config.database)\n      const userOneAccessToken = await getAccessToken(userOne.id, userOne.role, config.jwt)\n      const updateBody = {}\n      const res = await request(`/v1/users/${userOne.id}`, {\n        method: 'PATCH',\n        body: JSON.stringify(updateBody),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${userOneAccessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.BAD_REQUEST)\n    })\n    test('should return 403 if user has not verified their email', async () => {\n      await insertUsers([userTwo], config.database)\n      const accessToken = await getAccessToken(\n        userTwo.id,\n        userTwo.role,\n        config.jwt,\n        tokenTypes.ACCESS,\n        userTwo.is_email_verified\n      )\n      const updateBody = {}\n      const res = await request('/v1/users/1234', {\n        method: 'PATCH',\n        body: JSON.stringify(updateBody),\n        headers: {\n          'Content-Type': 'application/json',\n          Authorization: `Bearer ${accessToken}`\n        }\n      })\n      expect(res.status).toBe(httpStatus.FORBIDDEN)\n    })\n  })\n})\n"
  },
  {
    "path": "tests/mocks/awsClientStub/aws-client-stub.ts",
    "content": "import { Client, Command, MetadataBearer } from '@smithy/types'\nimport { MockInstance, vi, Mock } from 'vitest'\nimport { mockClient } from './mock-client'\n\nexport type AwsClientBehavior<TClient> =\n  TClient extends Client<infer TInput, infer TOutput, infer TConfiguration>\n    ? Behavior<TInput, TOutput, TOutput, TConfiguration>\n    : never\n\nexport interface Behavior<\n  TInput extends object,\n  TOutput extends MetadataBearer,\n  TCommandOutput extends TOutput,\n  TConfiguration\n> {\n  on<TCmdInput extends TInput, TCmdOutput extends TOutput>(\n    command: new (input: TCmdInput) => AwsCommand<TCmdInput, TCmdOutput, TInput, TOutput>,\n    input?: Partial<TCmdInput>,\n    strict?: boolean\n  ): Behavior<TInput, TOutput, TCmdOutput, TConfiguration>\n\n  resolves(response: CommandResponse<TCommandOutput>): AwsStub<TInput, TOutput, TConfiguration>\n\n  rejects(error?: string | Error | AwsError): AwsStub<TInput, TOutput, TConfiguration>\n}\n\n/**\n * Type for {@link AwsStub} class,\n * but with the AWS Client class type as an only generic parameter.\n *\n * @example\n * ```ts\n * let snsMock: AwsClientStub<SNSClient>;\n * snsMock = mockClient(SNSClient);\n * ```\n */\nexport type AwsClientStub<TClient> =\n  TClient extends Client<infer TInput, infer TOutput, infer TConfiguration>\n    ? AwsStub<TInput, TOutput, TConfiguration>\n    : never\n\ntype MockCall<In extends unknown[], Out> = {\n  args: In\n  result: MockResult<Out>\n}\n\ntype MockResult<T> =\n  | {\n      type: 'return'\n      value: T\n    }\n  | {\n      type: 'throw'\n      value: unknown\n    }\n\ntype Inputs<TInput extends object, TOutput extends MetadataBearer, TConfiguration> = Parameters<\n  Client<TInput, TOutput, TConfiguration>['send']\n>\ntype Output<TInput extends object, TOutput extends MetadataBearer, TConfiguration> = ReturnType<\n  Client<TInput, TOutput, TConfiguration>['send']\n>\n\n/**\n * Wrapper on the mocked `Client#send()` method,\n * allowing to configure its behavior.\n *\n * Without any configuration, `Client#send()` invocation returns `undefined`.\n *\n * To define resulting variable type easily, use {@link AwsClientStub}.\n */\nexport class AwsStub<TInput extends object, TOutput extends MetadataBearer, TConfiguration> {\n  /**\n   * Underlying `Client#send()` method Sinon stub.\n   *\n   * Install `@types/sinon` for TypeScript typings.\n   */\n  public send: MockInstance<\n    Inputs<TInput, TOutput, TConfiguration>,\n    Output<TInput, TOutput, TConfiguration>\n  >\n\n  constructor(\n    private client: Client<TInput, TOutput, TConfiguration>,\n    send: MockInstance<\n      Inputs<TInput, TOutput, TConfiguration>,\n      Output<TInput, TOutput, TConfiguration>\n    >\n  ) {\n    this.send = send\n  }\n\n  /** Returns the class name of the underlying mocked client class */\n  clientName(): string {\n    return this.client.constructor.name\n  }\n\n  /**\n   * Resets stub. It will replace the stub with a new one, with clean history and behavior.\n   */\n  reset(): AwsStub<TInput, TOutput, TConfiguration> {\n    /* sinon.stub.reset() does not remove the fakes which in some conditions can break subsequent stubs,\n     * so instead of calling send.reset(), we recreate the stub.\n     * See: https://github.com/sinonjs/sinon/issues/1572\n     * We are only affected by the broken reset() behavior of this bug, since we always use matchers.\n     */\n    const newStub = mockClient(this.client)\n    this.send = newStub.send\n    return this\n  }\n\n  /** Replaces stub with original `Client#send()` method. */\n  restore(): void {\n    this.send.mockRestore()\n  }\n\n  /**\n   * Returns recorded calls to the stub.\n   */\n  calls(): MockCall<\n    Inputs<TInput, TOutput, TConfiguration>,\n    Output<TInput, TOutput, TConfiguration>\n  >[] {\n    return this.send.mock.calls.map(\n      (call, i) =>\n        ({\n          args: call,\n          result: this.send.mock.results[i]\n        }) as MockCall<\n          Inputs<TInput, TOutput, TConfiguration>,\n          Output<TInput, TOutput, TConfiguration>\n        >\n    )\n  }\n\n  /**\n   * Returns n-th recorded call to the stub.\n   */\n  call(\n    n: number\n  ): MockCall<Inputs<TInput, TOutput, TConfiguration>, Output<TInput, TOutput, TConfiguration>> {\n    return this.calls()[n]\n  }\n\n  /**\n   * Allows specifying the behavior for a given Command type and its input (parameters).\n   *\n   * If the input is not specified, it will match any Command of that type.\n   *\n   * @example\n   * ```js\n   * snsMock\n   *   .on(PublishCommand, {Message: 'My message'})\n   *   .resolves({MessageId: '111'});\n   * ```\n   *\n   * @param command Command type to match\n   * @param input Command payload to match\n   * @param strict Should the payload match strictly (default false, will match if all defined payload properties match)\n   */\n  on<TCmdInput extends TInput, TCmdOutput extends TOutput>(\n    command: new (input: TCmdInput) => AwsCommand<TCmdInput, TCmdOutput, TInput, TOutput>\n  ): CommandBehavior<TInput, TOutput, TCmdOutput, TConfiguration> {\n    const cmdStub: Mock<\n      Inputs<TInput, TOutput, TConfiguration>,\n      Output<TInput, TOutput, TConfiguration>\n    > = vi.fn((cmd, opts, cb) => {\n      return this.client.send(cmd, opts, cb)\n    })\n    this.send.mockImplementation((cmd, opts, cb) => {\n      if (cmd instanceof command) return cmdStub(cmd, opts, cb)\n      return this.client.send(cmd, opts, cb)\n    })\n    return new CommandBehavior<TInput, TOutput, TCmdOutput, TConfiguration>(this, cmdStub)\n  }\n}\n\nexport class CommandBehavior<\n  TInput extends object,\n  TOutput extends MetadataBearer,\n  TCommandOutput extends TOutput,\n  TConfiguration\n> {\n  constructor(\n    private clientStub: AwsStub<TInput, TOutput, TConfiguration>,\n    private send: Mock<\n      Inputs<TInput, TOutput, TConfiguration>,\n      Output<TInput, TOutput, TConfiguration>\n    >\n  ) {}\n\n  /**\n   * Sets a successful response that will be returned from `Client#send()` invocation for the current `Command`.\n   *\n   * @example\n   * ```js\n   * snsMock\n   *   .on(PublishCommand)\n   *   .resolves({MessageId: '111'});\n   * ```\n   *\n   * @param response Content to be returned\n   */\n  resolves(\n    response: Awaited<CommandResponse<TCommandOutput>>\n  ): AwsStub<TInput, TOutput, TConfiguration> {\n    this.send.mockImplementation(() => Promise.resolve(response) as unknown as Promise<TOutput>)\n    return this.clientStub\n  }\n\n  /**\n   * Sets a failure response that will be returned from `Client#send()` invocation for the current `Command`.\n   * The response will always be an `Error` instance.\n   *\n   * @example\n   * ```js\n   * snsMock\n   *   .on(PublishCommand)\n   *   .rejects('mocked rejection');\n   *```\n   *\n   * @example\n   * ```js\n   * const throttlingError = new Error('mocked rejection');\n   * throttlingError.name = 'ThrottlingException';\n   * snsMock\n   *   .on(PublishCommand)\n   *   .rejects(throttlingError);\n   * ```\n   *\n   * @param error Error text, Error instance or Error parameters to be returned\n   */\n  rejects(error?: string | Error | AwsError): AwsStub<TInput, TOutput, TConfiguration> {\n    this.send.mockImplementation(() => Promise.reject(error))\n    return this.clientStub\n  }\n}\n\nexport type AwsCommand<\n  Input extends ClientInput,\n  Output extends ClientOutput,\n  ClientInput extends object,\n  ClientOutput extends MetadataBearer\n> = Command<ClientInput, Input, ClientOutput, Output, unknown>\ntype CommandResponse<TOutput> = Partial<TOutput> | PromiseLike<Partial<TOutput>>\n\nexport interface AwsError extends Partial<Error>, Partial<MetadataBearer> {\n  Type?: string\n  Code?: string\n  $fault?: 'client' | 'server'\n  $service?: string\n}\n"
  },
  {
    "path": "tests/mocks/awsClientStub/expect-mock.ts",
    "content": "import { AwsStub } from './aws-client-stub'\n\n// eslint-disable-next-line\nexport function toHaveReceivedCommandTimes(mock: AwsStub<any, any, any>, command: unknown, times: number) {\n  const calls = mock.send.mock.calls\n    // eslint-disable-next-line\n\t\t.filter((call) => call[0] instanceof (command as any))\n\t\t.length\n\n  return {\n    pass: calls === times,\n    message: () => `Function was called ${calls} times with input, expected ${times} calls`\n  }\n}\n\nexport const expectExtension = {\n  toHaveReceivedCommandTimes\n}\n\ninterface CustomMatchers<R = unknown> {\n  // eslint-disable-next-line\n\ttoHaveReceivedCommandTimes: (command: unknown, times: number) => R\n}\n\ndeclare module 'vitest' {\n  // eslint-disable-next-line\n\tinterface Assertion<T = any> extends CustomMatchers<T> {}\n  // eslint-disable-next-line\n\tinterface AsymmetricMatchersContaining extends CustomMatchers {}\n}\n"
  },
  {
    "path": "tests/mocks/awsClientStub/index.ts",
    "content": "export * from './mock-client'\nexport * from './aws-client-stub'\nexport * from './expect-mock'\n"
  },
  {
    "path": "tests/mocks/awsClientStub/mock-client.ts",
    "content": "import { Client, MetadataBearer } from '@smithy/types'\nimport { vi } from 'vitest'\nimport { AwsClientStub, AwsStub } from './aws-client-stub'\n\n/**\n * Creates and attaches a stub of the `Client#send()` method. Only this single method is mocked.\n * If method is already a stub, it's replaced.\n * @param client `Client` type or instance to replace the method\n * @param sandbox Optional sinon sandbox to use\n * @return Stub allowing to configure Client's behavior\n */\nexport const mockClient = <TInput extends object, TOutput extends MetadataBearer, TConfiguration>(\n  client: InstanceOrClassType<Client<TInput, TOutput, TConfiguration>>\n): AwsClientStub<Client<TInput, TOutput, TConfiguration>> => {\n  const instance = isClientInstance(client) ? client : client.prototype\n\n  // const send = instance.send;\n  // if (vi.isMockFunction(send)) {\n  //     send.restore();\n  // }\n\n  const sendStub = vi.spyOn(instance, 'send')\n\n  return new AwsStub<TInput, TOutput, TConfiguration>(instance, sendStub)\n}\n\ntype ClassType<T> = {\n  prototype: T\n}\n\ntype InstanceOrClassType<T> = T | ClassType<T>\n\n/**\n * Type guard to differentiate `Client` instance from a type.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst isClientInstance = <TClient extends Client<any, any, any>>(\n  obj: InstanceOrClassType<TClient>\n): obj is TClient => (obj as TClient).send !== undefined\n"
  },
  {
    "path": "tests/tsconfig.json",
    "content": "{\n  \"extends\": \"../tsconfig.json\",\n  \"compilerOptions\": {\n    \"moduleResolution\": \"bundler\",\n    \"types\": [\n      \"@types/bcryptjs\",\n      \"@cloudflare/workers-types/experimental\",\n      \"@cloudflare/vitest-pool-workers\",\n      \"vitest\"\n    ]\n  },\n  \"include\": [\"../src/**/*\", \"**/*\", \"../bindings.d.ts\", \"cloudflare-test.d.ts\", \"vitest.d.ts\"]\n}\n"
  },
  {
    "path": "tests/utils/clear-db-tables.ts",
    "content": "import { beforeEach } from 'vitest'\nimport { Config } from '../../src/config/config'\nimport { getDBClient, Database } from '../../src/config/database'\n\nconst clearDBTables = (tables: Array<keyof Database>, databaseConfig: Config['database']) => {\n  const client = getDBClient(databaseConfig)\n  beforeEach(async () => {\n    for (const table of tables) {\n      await client.deleteFrom(table).executeTakeFirst()\n    }\n  })\n}\n\nexport { clearDBTables }\n"
  },
  {
    "path": "tests/utils/test-request.ts",
    "content": "import { env } from 'cloudflare:test'\nimport app from '../../src'\nimport '../../src/routes'\n\nconst devUrl = 'http://localhost'\n\nclass Context implements ExecutionContext {\n  passThroughOnException(): void {\n    throw new Error('Method not implemented.')\n  }\n  abort(): void {}\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  async waitUntil(promise: Promise<any>): Promise<void> {\n    await promise\n  }\n}\n\nconst request = async (path: string, options: RequestInit) => {\n  const formattedUrl = new URL(path, devUrl).href\n  const request = new Request(formattedUrl, options)\n  return app.fetch(request, env, new Context())\n}\n\nexport { request }\n"
  },
  {
    "path": "tests/vitest.d.ts",
    "content": "/* eslint-disable @typescript-eslint/no-empty-object-type */\nimport 'vitest'\nimport { CustomMatcher } from 'aws-sdk-client-mock-vitest'\n\ndeclare module 'vitest' {\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  interface Assertion<T = any> extends CustomMatcher<T> {}\n  interface AsymmetricMatchersContaining extends CustomMatcher {}\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"allowJs\": true,\n    \"target\": \"esnext\",\n    \"lib\": [\"esnext\"],\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"inlineSourceMap\": true,\n    \"module\": \"esnext\",\n    \"esModuleInterop\": true,\n    \"experimentalDecorators\": true,\n    \"emitDecoratorMetadata\": true,\n    \"strict\": true,\n    \"noImplicitAny\": true,\n    \"noEmit\": true,\n    \"skipLibCheck\": true\n  },\n  \"ts-node\": {\n    \"transpileOnly\": true\n  },\n  \"include\": [\"./src/**/*\", \"bindings.d.ts\"]\n}\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'\n\nexport default defineWorkersConfig({\n  test: {\n    poolOptions: {\n      workers: {\n        wrangler: { configPath: 'wrangler.toml', environment: 'test' },\n        isolatedStorage: true,\n        singleWorker: true\n      }\n    }\n  }\n})\n"
  },
  {
    "path": "wrangler.toml.example",
    "content": "name = 'cf-workers-hono-planetscale-app'\nmain = 'dist/index.mjs'\n\nworkers_dev = true\ncompatibility_date = '2024-08-23'\ncompatability_flags = [2nodejs_compat']\naccount_id=''\n\n[durable_objects]\nbindings = [\n  { name = 'RATE_LIMITER', class_name = 'RateLimiter' }\n]\n\n[env.test.durable_objects]\nbindings = [\n  { name = 'RATE_LIMITER', class_name = 'RateLimiter' }\n]\n\n[[migrations]]\ntag = 'v1'\nnew_classes = ['RateLimiter']\n\n[[env.test.migrations]]\ntag = 'v1'\nnew_classes = ['RateLimiter']\n\n[env.test.vars]\nENV = 'development'\nJWT_ACCESS_EXPIRATION_MINUTES=30\nJWT_REFRESH_EXPIRATION_DAYS=30\nJWT_RESET_PASSWORD_EXPIRATION_MINUTES=15\nJWT_VERIFY_EMAIL_EXPIRATION_MINUTES=15\nDATABASE_NAME='example'\nDATABASE_USERNAME='example'\nDATABASE_HOST='example'\nAWS_REGION='eu-west-1'\nEMAIL_SENDER='noreply@gmail.com'\nOAUTH_GITHUB_CLIENT_ID='myclientid'\nOAUTH_DISCORD_CLIENT_ID='myclientid'\nOAUTH_DISCORD_REDIRECT_URL='https://frontend.com/login'\nOAUTH_SPOTIFY_CLIENT_ID='myclientid'\nOAUTH_SPOTIFY_REDIRECT_URL='https://frontend.com/login'\nOAUTH_GOOGLE_CLIENT_ID='myclientid'\nOAUTH_GOOGLE_REDIRECT_URL='https://frontend.com/login'\nOAUTH_FACEBOOK_CLIENT_ID='myclientid'\nOAUTH_FACEBOOK_REDIRECT_URL='https://frontend.com/login'\nOAUTH_APPLE_CLIENT_ID='com.your.app'\nOAUTH_APPLE_KEY_ID='randomid'\nOAUTH_APPLE_TEAM_ID='randomid'\nOAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES=30\nOAUTH_APPLE_REDIRECT_URL='https://api.com/v1/auth/apple/callback'\n\n\n[vars]\nENV = 'development'\nJWT_ACCESS_EXPIRATION_MINUTES=30\nJWT_REFRESH_EXPIRATION_DAYS=30\nJWT_RESET_PASSWORD_EXPIRATION_MINUTES=15\nJWT_VERIFY_EMAIL_EXPIRATION_MINUTES=15\nDATABASE_NAME='example'\nDATABASE_USERNAME='example'\nDATABASE_HOST='example'\nAWS_REGION='eu-west-1'\nEMAIL_SENDER='noreply@gmail.com'\nOAUTH_GITHUB_CLIENT_ID='myclientid'\nOAUTH_DISCORD_CLIENT_ID='myclientid'\nOAUTH_DISCORD_REDIRECT_URL='https://frontend.com/login'\nOAUTH_SPOTIFY_CLIENT_ID='myclientid'\nOAUTH_SPOTIFY_REDIRECT_URL='https://frontend.com/login'\nOAUTH_GOOGLE_CLIENT_ID='myclientid'\nOAUTH_GOOGLE_REDIRECT_URL='https://frontend.com/login'\nOAUTH_FACEBOOK_CLIENT_ID='myclientid'\nOAUTH_FACEBOOK_REDIRECT_URL='https://frontend.com/login'\nOAUTH_APPLE_CLIENT_ID='com.your.app'\nOAUTH_APPLE_KEY_ID='randomid'\nOAUTH_APPLE_TEAM_ID='randomid'\nOAUTH_APPLE_JWT_ACCESS_EXPIRATION_MINUTES=30\nOAUTH_APPLE_REDIRECT_URL='https://api.com/v1/auth/apple/callback'\n\n[build]\ncommand = 'npm run build'\n# [secrets]\n# JWT_SECRET\n# DATABASE_PASSWORD\n# AWS_ACCESS_KEY_ID\n# AWS_SECRET_ACCESS_KEY\n# SENTRY_DSN\n# OAUTH_GITHUB_CLIENT_SECRET\n# OAUTH_DISCORD_CLIENT_SECRET\n# OAUTH_SPOTIFY_CLIENT_SECRET\n# OAUTH_GOOGLE_CLIENT_SECRET\n# OAUTH_FACEBOOK_CLIENT_SECRET\n# OAUTH_APPLE_PRIVATE_KEY\n"
  }
]