diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 674b466b5e..1ffe70bd6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,51 @@ jobs: - name: Build run: pnpm build + unit-test: + name: Unit Tests + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + container: node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284 + permissions: + checks: write + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Pnpm Setup + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + + - name: Get pnpm store directory + shell: sh + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + env: + CI: true + run: pnpm install + + - name: Run tests + env: + CI: true + run: pnpm test + + - name: Publish test report + uses: mikepenz/action-junit-report@v6 + if: success() || failure() # always run even if the previous step fails + with: + report_paths: 'report.xml' + build: name: Build (per-arch, native runners) if: github.ref == 'refs/heads/develop' diff --git a/.gitignore b/.gitignore index d294bc0914..dc3bd55b6a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # testing /coverage +lcov.info # next.js /.next/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8dc1918fbf..b20545471b 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -16,6 +16,7 @@ "stylelint.vscode-stylelint", - "bradlc.vscode-tailwindcss" + "bradlc.vscode-tailwindcss", + "firsttris.vscode-jest-runner" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index dbb4a2ccf8..fec338ab5d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,5 +23,12 @@ "i18n-ally.localesPaths": [ "src/i18n/locale" ], - "yaml.format.singleQuote": true + "yaml.format.singleQuote": true, + "jestrunner.enableTestExplorer": true, + "jestrunner.defaultTestPatterns": [ + "server/**/*.{test,spec}.?(c|m)[jt]s?(x)", + ], + "jestrunner.nodeTestCommand": "pnpm test", + "jestrunner.changeDirectoryToWorkspaceRoot": true, + "jestrunner.projectPath": "." } diff --git a/package.json b/package.json index d38b779d82..36634911fc 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build": "pnpm build:next && pnpm build:server", "lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache", "lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix", + "test": "node server/test/index.mts", "start": "NODE_ENV=production node dist/index.js", "i18n:extract": "ts-node --project server/tsconfig.json src/i18n/extractMessages.ts", "migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts", @@ -73,6 +74,7 @@ "node-gyp": "9.3.1", "node-schedule": "2.1.1", "nodemailer": "7.0.12", + "openid-client": "^6.8.2", "openpgp": "6.3.0", "pg": "8.17.2", "pug": "3.0.3", @@ -135,6 +137,7 @@ "@types/react-transition-group": "4.4.12", "@types/secure-random-password": "0.2.1", "@types/semver": "7.7.1", + "@types/supertest": "^6.0.3", "@types/swagger-ui-express": "4.1.8", "@types/validator": "^13.15.10", "@types/web-push": "3.6.4", @@ -145,6 +148,7 @@ "@typescript-eslint/parser": "7.18.0", "autoprefixer": "^10.4.23", "baseline-browser-mapping": "^2.8.32", + "commander": "^14.0.3", "commitizen": "4.3.1", "copyfiles": "2.4.1", "cy-mobile-commands": "0.3.0", @@ -159,14 +163,19 @@ "eslint-plugin-prettier": "4.2.1", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "4.6.0", + "fetch-mock": "^12.6.0", "husky": "8.0.3", + "jose": "^6.1.3", "lint-staged": "13.1.2", + "node-test-github-reporter": "^1.3.1", "nodemon": "3.1.11", "postcss": "^8.5.6", "prettier": "3.8.1", "prettier-plugin-organize-imports": "4.3.0", "prettier-plugin-tailwindcss": "0.6.14", + "supertest": "^7.2.2", "tailwindcss": "3.4.19", + "ts-jest": "^29.4.6", "ts-node": "10.9.2", "tsc-alias": "1.8.16", "tsconfig-paths": "4.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8933e19175..a6745df219 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: nodemailer: specifier: 7.0.12 version: 7.0.12 + openid-client: + specifier: ^6.8.2 + version: 6.8.2 openpgp: specifier: 6.3.0 version: 6.3.0 @@ -316,6 +319,9 @@ importers: '@types/semver': specifier: 7.7.1 version: 7.7.1 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 '@types/swagger-ui-express': specifier: 4.1.8 version: 4.1.8 @@ -346,6 +352,9 @@ importers: baseline-browser-mapping: specifier: ^2.8.32 version: 2.9.18 + commander: + specifier: ^14.0.3 + version: 14.0.3 commitizen: specifier: 4.3.1 version: 4.3.1(@types/node@22.10.5)(typescript@5.4.5) @@ -372,7 +381,7 @@ importers: version: 8.6.0(eslint@8.57.1) eslint-plugin-formatjs: specifier: 4.9.0 - version: 4.9.0(eslint@8.57.1) + version: 4.9.0(eslint@8.57.1)(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)))(typescript@5.4.5)) eslint-plugin-jsx-a11y: specifier: 6.10.2 version: 6.10.2(eslint@8.57.1) @@ -388,12 +397,21 @@ importers: eslint-plugin-react-hooks: specifier: 4.6.0 version: 4.6.0(eslint@8.57.1) + fetch-mock: + specifier: ^12.6.0 + version: 12.6.0 husky: specifier: 8.0.3 version: 8.0.3 + jose: + specifier: ^6.1.3 + version: 6.1.3 lint-staged: specifier: 13.1.2 version: 13.1.2(enquirer@2.4.1) + node-test-github-reporter: + specifier: ^1.3.1 + version: 1.3.1 nodemon: specifier: 3.1.11 version: 3.1.11 @@ -409,9 +427,15 @@ importers: prettier-plugin-tailwindcss: specifier: 0.6.14 version: 0.6.14(prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.4.5))(prettier@3.8.1) + supertest: + specifier: ^7.2.2 + version: 7.2.2 tailwindcss: specifier: 3.4.19 version: 3.4.19(yaml@2.8.2) + ts-jest: + specifier: ^29.4.6 + version: 29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)))(typescript@5.4.5) ts-node: specifier: 10.9.2 version: 10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5) @@ -427,6 +451,18 @@ importers: packages: + '@actions/core@1.11.1': + resolution: {integrity: sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==} + + '@actions/exec@1.1.1': + resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==} + + '@actions/http-client@2.2.3': + resolution: {integrity: sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==} + + '@actions/io@1.1.3': + resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -651,6 +687,11 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-class-properties@7.12.13': resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} peerDependencies: @@ -1165,6 +1206,9 @@ packages: resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} @@ -1276,9 +1320,18 @@ packages: resolution: {integrity: sha512-nUACXbE+oi3spzU0bEff2L1P2qUUuoc6ppynNqM/p7OSElSIiR3H9T4e4VIPRilUHXq6uT3C+cGfSOXt9rCU5w==} engines: {node: '>=20'} + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/runtime@1.2.0': resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -1373,6 +1426,10 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -1633,22 +1690,108 @@ packages: resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} engines: {node: '>=12'} + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/console@30.2.0': + resolution: {integrity: sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/core@30.2.0': + resolution: {integrity: sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + '@jest/create-cache-key-function@29.7.0': resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/diff-sequences@30.0.1': + resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/environment@29.7.0': resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/environment@30.2.0': + resolution: {integrity: sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.2.0': + resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect@30.2.0': + resolution: {integrity: sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/fake-timers@29.7.0': resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/fake-timers@30.2.0': + resolution: {integrity: sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@30.2.0': + resolution: {integrity: sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.0.1': + resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/reporters@30.2.0': + resolution: {integrity: sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/snapshot-utils@30.2.0': + resolution: {integrity: sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@30.0.1': + resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-result@30.2.0': + resolution: {integrity: sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-sequencer@30.2.0': + resolution: {integrity: sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/transform@30.2.0': + resolution: {integrity: sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/types@26.6.2': resolution: {integrity: sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==} engines: {node: '>= 10.14.2'} @@ -1657,6 +1800,10 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/types@30.2.0': + resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1858,6 +2005,9 @@ packages: '@messageformat/runtime@3.0.2': resolution: {integrity: sha512-dkIPDCjXcfhSHgNE1/qV6TeczQZR59Yx0xXeafVKgK3QVWoxc38ljwpksUpnzCGvN151KUbCJTDZVmahtf1YZw==} + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@next/env@14.2.35': resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==} @@ -1918,6 +2068,10 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1947,10 +2101,17 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This functionality has been moved to @npmcli/fs + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -2693,12 +2854,18 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@sinclair/typebox@0.34.48': + resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} + '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@sinonjs/fake-timers@13.0.5': + resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} + '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} @@ -2912,6 +3079,21 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcrypt@6.0.0': resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} @@ -2929,6 +3111,9 @@ packages: peerDependencies: '@types/express': '*' + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/country-flag-icons@1.2.2': resolution: {integrity: sha512-CefEn/J336TBDp7NX8JqzlDtCBOsm8M3r1Li0gEOt0HOMHF1XemNyrx9lSHjsafcb1yYWybU0N8ZAXuyCaND0w==} @@ -2959,6 +3144,9 @@ packages: '@types/express@4.17.17': resolution: {integrity: sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==} + '@types/glob-to-regexp@0.4.4': + resolution: {integrity: sha512-nDKoaKJYbnn1MZxUY0cA1bPmmgZbg0cTq7Rh13d0KWYNOiKbqoR+2d89SnRPszGh7ROzSwZ/GOjZ4jPbmmZ6Eg==} + '@types/hast@2.3.10': resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} @@ -2995,6 +3183,9 @@ packages: '@types/mdast@3.0.15': resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -3092,6 +3283,12 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/swagger-ui-express@4.1.8': resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==} @@ -3274,6 +3471,101 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -3563,9 +3855,23 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + babel-jest@30.2.0: + resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + babel-plugin-emotion@10.2.2: resolution: {integrity: sha512-SMSkGoqTbTyUTDeuVuPIWifPdUGkTk1Kf9BWRiXIOIcuyMfsdp2EjeiiFvOzX8NOBvEh/ypKYvUh2rkgAJMCLA==} + babel-plugin-istanbul@7.0.1: + resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} + engines: {node: '>=12'} + + babel-plugin-jest-hoist@30.2.0: + resolution: {integrity: sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + babel-plugin-macros@2.8.0: resolution: {integrity: sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==} @@ -3599,6 +3905,17 @@ packages: babel-plugin-transform-flow-enums@0.0.2: resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@30.2.0: + resolution: {integrity: sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 + babel-walk@3.0.0-canary-5: resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} engines: {node: '>= 10.0.0'} @@ -3674,6 +3991,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -3785,6 +4106,10 @@ packages: resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} @@ -3832,6 +4157,9 @@ packages: resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} engines: {node: '>=8'} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} @@ -3892,6 +4220,13 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -3952,6 +4287,10 @@ packages: command-exists@1.2.9: resolution: {integrity: sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -3990,6 +4329,9 @@ packages: compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -4079,6 +4421,9 @@ packages: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} @@ -4353,6 +4698,9 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -4453,6 +4801,10 @@ packages: resolution: {integrity: sha512-tCjkmZYakXkKfL3/qZJ7esCa04KP5zIpcuEjw9EPLQrLbTUUkX6w9MMc37zGj2nJvIpFBc1lUudHi5DkZqiNJQ==} engines: {node: '>=14'} + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + emoji-regex@10.3.0: resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==} @@ -4758,6 +5110,10 @@ packages: resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} engines: {node: '>=4'} + exit-x@0.2.2: + resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} + engines: {node: '>= 0.8.0'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -4766,6 +5122,10 @@ packages: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} + expect@30.2.0: + resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} @@ -4829,6 +5189,9 @@ packages: resolution: {integrity: sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==} engines: {node: '>=10.0'} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-xml-parser@4.5.3: resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} hasBin: true @@ -4854,6 +5217,10 @@ packages: fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + fetch-mock@12.6.0: + resolution: {integrity: sha512-oAy0OqAvjAvduqCeWveBix7LLuDbARPqZZ8ERYtBcCURA3gy7EALA3XWq0tCNxsSg+RmmJqyaeeZlOCV9abv6w==} + engines: {node: '>=18.11.0'} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -4951,6 +5318,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + formik@2.4.9: resolution: {integrity: sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==} peerDependencies: @@ -5021,6 +5392,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + get-paths@0.0.7: resolution: {integrity: sha512-0wdJt7C1XKQxuCgouqd+ZvLJ56FQixKoki9MrFaO4EriqzXOiH9gbukaDE1ou08S8Ns3/yDzoBAISNPqj6e6tA==} engines: {node: '>=6.4'} @@ -5074,6 +5449,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.3.10: resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} engines: {node: '>=16 || 14 >=14.17'} @@ -5221,6 +5599,9 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-text@9.0.5: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} @@ -5335,6 +5716,11 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + import-meta-resolve@4.1.0: resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} @@ -5470,6 +5856,10 @@ packages: resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} engines: {node: '>=12'} + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + is-generator-function@1.0.10: resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} engines: {node: '>= 0.4'} @@ -5621,6 +6011,26 @@ packages: isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -5637,34 +6047,162 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jest-changed-files@30.2.0: + resolution: {integrity: sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-circus@30.2.0: + resolution: {integrity: sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-cli@30.2.0: + resolution: {integrity: sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@30.2.0: + resolution: {integrity: sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + + jest-diff@30.2.0: + resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@30.2.0: + resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-each@30.2.0: + resolution: {integrity: sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-environment-node@29.7.0: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-environment-node@30.2.0: + resolution: {integrity: sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-get-type@29.6.3: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-haste-map@30.2.0: + resolution: {integrity: sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-leak-detector@30.2.0: + resolution: {integrity: sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.2.0: + resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@29.7.0: resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-message-util@30.2.0: + resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@29.7.0: resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-mock@30.2.0: + resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@30.0.1: + resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve-dependencies@30.2.0: + resolution: {integrity: sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve@30.2.0: + resolution: {integrity: sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runner@30.2.0: + resolution: {integrity: sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runtime@30.2.0: + resolution: {integrity: sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-snapshot@30.2.0: + resolution: {integrity: sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-util@29.7.0: resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-util@30.2.0: + resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-validate@29.7.0: resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-validate@30.2.0: + resolution: {integrity: sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-watcher@30.2.0: + resolution: {integrity: sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-worker@29.7.0: resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-worker@30.2.0: + resolution: {integrity: sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest@30.2.0: + resolution: {integrity: sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + jiti@1.21.6: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true @@ -5676,6 +6214,9 @@ packages: joi@17.13.3: resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-stringify@1.0.2: resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} @@ -5938,6 +6479,9 @@ packages: lodash.map@4.6.0: resolution: {integrity: sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==} + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -6035,6 +6579,10 @@ packages: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -6430,6 +6978,11 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -6532,6 +7085,12 @@ packages: resolution: {integrity: sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==} engines: {node: '>=0.12.0'} + node-test-github-reporter@1.3.1: + resolution: {integrity: sha512-SHWaJmTClc1DAEu0UFvjvw2AK2pOhoos2YDifTc59VhbUI7Ro0j4xlqZtqMVuGd/s9gMrQspkhwHCMFDjk+0cg==} + + node-test-parser@3.1.0: + resolution: {integrity: sha512-e9CLgS/wOMVlpWI63U27wxv8Lq0jMiO2JWirhRKflLsUMHWVoh/ruEibsw1YDCgOwHZiSyNW/QXNTo9cuB7Qpw==} + nodemailer@6.10.0: resolution: {integrity: sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==} engines: {node: '>=6.0.0'} @@ -6596,6 +7155,9 @@ packages: nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + oauth4webapi@3.8.4: + resolution: {integrity: sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ==} + ob1@0.80.12: resolution: {integrity: sha512-VMArClVT6LkhUGpnuEoBuyjG9rzUyEzg4PDkav6wK1cLhOK02gPCYFxoiB4mqVnrMhDpIzJcrGNAMVi9P+hXrw==} engines: {node: '>=18'} @@ -6677,6 +7239,9 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} + openid-client@6.8.2: + resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} + openpgp@6.3.0: resolution: {integrity: sha512-pLzCU8IgyKXPSO11eeharQkQ4GzOKNWhXq79pQarIRZEMt1/ssyr+MIuWBv1mNoenJLg04gvPx+fi4gcKZ4bag==} engines: {node: '>= 18.0.0'} @@ -6892,6 +7457,10 @@ packages: resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} engines: {node: '>=6'} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + plimit-lit@1.6.1: resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} engines: {node: '>=12'} @@ -7072,6 +7641,10 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@30.2.0: + resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + preview-email@3.1.0: resolution: {integrity: sha512-ZtV1YrwscEjlrUzYrTSs6Nwo49JM3pXLM4fFOBSC3wSni+bxaWlw9/Qgk75PZO8M7cX2EybmL2iwvaV3vkAttw==} engines: {node: '>=14'} @@ -7174,6 +7747,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + q@1.5.1: resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} engines: {node: '>=0.6.0', teleport: '>=0.2.0'} @@ -7453,6 +8029,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + regexparam@3.0.0: + resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==} + engines: {node: '>=8'} + regexpu-core@6.4.0: resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} engines: {node: '>=4'} @@ -7487,6 +8067,10 @@ packages: resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + resolve-dir@1.0.1: resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==} engines: {node: '>=0.10.0'} @@ -7792,6 +8376,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -7897,6 +8484,10 @@ packages: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -8012,6 +8603,14 @@ packages: resolution: {integrity: sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -8055,6 +8654,10 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} @@ -8092,6 +8695,10 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + text-extensions@1.9.0: resolution: {integrity: sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==} engines: {node: '>=0.10'} @@ -8225,6 +8832,33 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-jest@29.4.6: + resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -8273,6 +8907,10 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} @@ -8308,6 +8946,10 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -8434,6 +9076,10 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + undici@7.18.2: resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} @@ -8504,6 +9150,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + untildify@4.0.0: resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} engines: {node: '>=8'} @@ -8558,6 +9207,10 @@ packages: v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + valid-data-url@3.0.1: resolution: {integrity: sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==} engines: {node: '>=10'} @@ -8698,6 +9351,10 @@ packages: write-file-atomic@2.4.3: resolution: {integrity: sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==} + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ws@6.2.3: resolution: {integrity: sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==} peerDependencies: @@ -8815,6 +9472,22 @@ packages: snapshots: + '@actions/core@1.11.1': + dependencies: + '@actions/exec': 1.1.1 + '@actions/http-client': 2.2.3 + + '@actions/exec@1.1.1': + dependencies: + '@actions/io': 1.1.3 + + '@actions/http-client@2.2.3': + dependencies: + tunnel: 0.0.6 + undici: 5.29.0 + + '@actions/io@1.1.3': {} + '@alloc/quick-lru@5.2.0': {} '@apidevtools/json-schema-ref-parser@9.0.9': @@ -9097,6 +9770,11 @@ snapshots: '@babel/core': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 @@ -9758,6 +10436,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} + '@colors/colors@1.6.0': {} '@commitlint/cli@17.4.4(@swc/core@1.6.5(@swc/helpers@0.5.11))': @@ -9966,11 +10646,27 @@ snapshots: tsscmp: 1.0.6 uid-safe: 2.1.5 + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.2.0': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.28.6 @@ -10107,6 +10803,8 @@ snapshots: '@eslint/js@8.57.1': {} + '@fastify/busboy@2.1.1': {} + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -10233,7 +10931,7 @@ snapshots: optionalDependencies: typescript: 5.4.5 - '@formatjs/ts-transformer@3.12.0': + '@formatjs/ts-transformer@3.12.0(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)))(typescript@5.4.5))': dependencies: '@formatjs/icu-messageformat-parser': 2.3.0 '@types/json-stable-stringify': 1.0.36 @@ -10242,6 +10940,8 @@ snapshots: json-stable-stringify: 1.1.1 tslib: 2.8.1 typescript: 4.9.5 + optionalDependencies: + ts-jest: 29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)))(typescript@5.4.5) '@gar/promisify@1.1.3': {} @@ -10382,10 +11082,67 @@ snapshots: '@isaacs/ttlcache@1.4.1': {} + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@30.2.0': + dependencies: + '@jest/types': 30.2.0 + '@types/node': 22.10.5 + chalk: 4.1.2 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + slash: 3.0.0 + + '@jest/core@30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5))': + dependencies: + '@jest/console': 30.2.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 22.10.5 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.3.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.2.0 + jest-config: 30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)) + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-resolve-dependencies: 30.2.0 + jest-runner: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-watcher: 30.2.0 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + '@jest/create-cache-key-function@29.7.0': dependencies: '@jest/types': 29.6.3 + '@jest/diff-sequences@30.0.1': {} + '@jest/environment@29.7.0': dependencies: '@jest/fake-timers': 29.7.0 @@ -10393,6 +11150,24 @@ snapshots: '@types/node': 22.10.5 jest-mock: 29.7.0 + '@jest/environment@30.2.0': + dependencies: + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 22.10.5 + jest-mock: 30.2.0 + + '@jest/expect-utils@30.2.0': + dependencies: + '@jest/get-type': 30.1.0 + + '@jest/expect@30.2.0': + dependencies: + expect: 30.2.0 + jest-snapshot: 30.2.0 + transitivePeerDependencies: + - supports-color + '@jest/fake-timers@29.7.0': dependencies: '@jest/types': 29.6.3 @@ -10402,10 +11177,114 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 + '@jest/fake-timers@30.2.0': + dependencies: + '@jest/types': 30.2.0 + '@sinonjs/fake-timers': 13.0.5 + '@types/node': 22.10.5 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + + '@jest/get-type@30.1.0': {} + + '@jest/globals@30.2.0': + dependencies: + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/types': 30.2.0 + jest-mock: 30.2.0 + transitivePeerDependencies: + - supports-color + + '@jest/pattern@30.0.1': + dependencies: + '@types/node': 22.10.5 + jest-regex-util: 30.0.1 + + '@jest/reporters@30.2.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 22.10.5 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 10.5.0 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + jest-worker: 30.2.0 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.8 + '@jest/schemas@30.0.5': + dependencies: + '@sinclair/typebox': 0.34.48 + + '@jest/snapshot-utils@30.2.0': + dependencies: + '@jest/types': 30.2.0 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + '@jest/source-map@30.0.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@30.2.0': + dependencies: + '@jest/console': 30.2.0 + '@jest/types': 30.2.0 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@30.2.0': + dependencies: + '@jest/test-result': 30.2.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + slash: 3.0.0 + + '@jest/transform@30.2.0': + dependencies: + '@babel/core': 7.28.6 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-regex-util: 30.0.1 + jest-util: 30.2.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + '@jest/types@26.6.2': dependencies: '@types/istanbul-lib-coverage': 2.0.6 @@ -10423,6 +11302,16 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jest/types@30.2.0': + dependencies: + '@jest/pattern': 30.0.1 + '@jest/schemas': 30.0.5 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.10.5 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -10509,6 +11398,13 @@ snapshots: dependencies: make-plural: 7.4.0 + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + '@next/env@14.2.35': {} '@next/eslint-plugin-next@14.2.35': @@ -10542,6 +11438,8 @@ snapshots: '@next/swc-win32-x64-msvc@14.2.33': optional: true + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10576,9 +11474,15 @@ snapshots: mkdirp: 1.0.4 rimraf: 3.0.2 + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@pkgjs/parseargs@0.11.0': optional: true + '@pkgr/core@0.2.9': {} + '@popperjs/core@2.11.8': {} '@react-aria/breadcrumbs@3.5.29(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -11949,6 +12853,8 @@ snapshots: '@sinclair/typebox@0.27.8': {} + '@sinclair/typebox@0.34.48': {} + '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 @@ -11957,6 +12863,10 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers@13.0.5': + dependencies: + '@sinonjs/commons': 3.0.1 + '@so-ric/colorspace@1.1.6': dependencies: color: 5.0.3 @@ -12158,6 +13068,32 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.6 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.6 + '@types/bcrypt@6.0.0': dependencies: '@types/node': 22.10.5 @@ -12180,6 +13116,8 @@ snapshots: dependencies: '@types/express': 4.17.17 + '@types/cookiejar@2.1.5': {} + '@types/country-flag-icons@1.2.2': {} '@types/csurf@1.11.5': @@ -12225,6 +13163,8 @@ snapshots: '@types/qs': 6.9.15 '@types/serve-static': 1.15.7 + '@types/glob-to-regexp@0.4.4': {} + '@types/hast@2.3.10': dependencies: '@types/unist': 2.0.10 @@ -12260,6 +13200,8 @@ snapshots: dependencies: '@types/unist': 2.0.10 + '@types/methods@1.1.4': {} + '@types/mime@1.3.5': {} '@types/mime@3.0.4': {} @@ -12352,6 +13294,18 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 22.10.5 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + '@types/swagger-ui-express@4.1.8': dependencies: '@types/express': 4.17.17 @@ -12591,6 +13545,65 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + JSONStream@1.3.5: dependencies: jsonparse: 1.3.1 @@ -12874,6 +13887,19 @@ snapshots: dependencies: '@babel/core': 7.28.6 + babel-jest@30.2.0(@babel/core@7.28.6): + dependencies: + '@babel/core': 7.28.6 + '@jest/transform': 30.2.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 7.0.1 + babel-preset-jest: 30.2.0(@babel/core@7.28.6) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + babel-plugin-emotion@10.2.2: dependencies: '@babel/helper-module-imports': 7.28.6 @@ -12889,6 +13915,20 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-istanbul@7.0.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 6.0.3 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@30.2.0: + dependencies: + '@types/babel__core': 7.20.5 + babel-plugin-macros@2.8.0: dependencies: '@babel/runtime': 7.28.6 @@ -12941,6 +13981,31 @@ snapshots: transitivePeerDependencies: - '@babel/core' + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.6): + dependencies: + '@babel/core': 7.28.6 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.6) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.6) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.6) + '@babel/plugin-syntax-import-attributes': 7.24.7(@babel/core@7.28.6) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.6) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.6) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.6) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.6) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.6) + + babel-preset-jest@30.2.0(@babel/core@7.28.6): + dependencies: + '@babel/core': 7.28.6 + babel-plugin-jest-hoist: 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.6) + babel-walk@3.0.0-canary-5: dependencies: '@babel/types': 7.28.6 @@ -13026,6 +14091,10 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + bser@2.1.1: dependencies: node-int64: 0.4.0 @@ -13170,6 +14239,8 @@ snapshots: chalk@5.3.0: optional: true + char-regex@1.0.2: {} + character-entities@2.0.2: {} character-parser@2.2.0: @@ -13230,6 +14301,8 @@ snapshots: ci-info@4.3.1: {} + cjs-module-lexer@2.2.0: {} + classnames@2.5.1: {} clean-stack@2.2.0: {} @@ -13290,6 +14363,10 @@ snapshots: clsx@2.1.1: {} + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -13344,6 +14421,8 @@ snapshots: command-exists@1.2.9: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -13385,6 +14464,8 @@ snapshots: array-ify: 1.0.0 dot-prop: 5.3.0 + component-emitter@1.3.1: {} + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -13484,6 +14565,8 @@ snapshots: cookie@1.0.2: {} + cookiejar@2.1.4: {} + copy-to-clipboard@3.3.3: dependencies: toggle-selection: 1.0.6 @@ -13801,8 +14884,12 @@ snapshots: detect-libc@2.0.3: {} - detect-newline@3.1.0: - optional: true + detect-newline@3.1.0: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 didyoumean@1.2.2: {} @@ -13970,6 +15057,8 @@ snapshots: - walrus - whiskers + emittery@0.13.1: {} + emoji-regex@10.3.0: {} emoji-regex@8.0.0: {} @@ -14214,10 +15303,10 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-formatjs@4.9.0(eslint@8.57.1): + eslint-plugin-formatjs@4.9.0(eslint@8.57.1)(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)))(typescript@5.4.5)): dependencies: '@formatjs/icu-messageformat-parser': 2.3.0 - '@formatjs/ts-transformer': 3.12.0 + '@formatjs/ts-transformer': 3.12.0(ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)))(typescript@5.4.5)) '@types/eslint': 8.56.10 '@types/picomatch': 2.3.3 '@typescript-eslint/typescript-estree': 5.45.0(typescript@4.9.5) @@ -14449,12 +15538,23 @@ snapshots: dependencies: pify: 2.3.0 + exit-x@0.2.2: {} + expand-template@2.0.3: {} expand-tilde@2.0.2: dependencies: homedir-polyfill: 1.0.3 + expect@30.2.0: + dependencies: + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + exponential-backoff@3.1.3: {} express-openapi-validator@4.13.8: @@ -14568,6 +15668,8 @@ snapshots: fast-printf@1.6.10: {} + fast-safe-stringify@2.1.1: {} + fast-xml-parser@4.5.3: dependencies: strnum: 1.1.2 @@ -14590,6 +15692,13 @@ snapshots: fecha@4.2.3: {} + fetch-mock@12.6.0: + dependencies: + '@types/glob-to-regexp': 0.4.4 + dequal: 2.0.3 + glob-to-regexp: 0.4.1 + regexparam: 3.0.0 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -14711,6 +15820,12 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + formik@2.4.9(react@18.3.1): dependencies: '@types/hoist-non-react-statics': 3.3.5 @@ -14800,6 +15915,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-package-type@0.1.0: {} + get-paths@0.0.7: dependencies: pify: 4.0.1 @@ -14857,6 +15974,8 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} + glob@10.3.10: dependencies: foreground-child: 3.2.1 @@ -14955,7 +16074,6 @@ snapshots: wordwrap: 1.0.0 optionalDependencies: uglify-js: 3.19.3 - optional: true hard-rejection@2.1.0: {} @@ -15025,6 +16143,8 @@ snapshots: dependencies: lru-cache: 6.0.0 + html-escaper@2.0.2: {} + html-to-text@9.0.5: dependencies: '@selderee/plugin-htmlparser2': 0.11.0 @@ -15176,6 +16296,11 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + import-meta-resolve@4.1.0: optional: true @@ -15310,6 +16435,8 @@ snapshots: is-fullwidth-code-point@4.0.0: {} + is-generator-fn@2.1.0: {} + is-generator-function@1.0.10: dependencies: has-tostringtag: 1.0.2 @@ -15428,6 +16555,37 @@ snapshots: isstream@0.1.2: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.28.6 + '@babel/parser': 7.28.6 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3(supports-color@5.5.0) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -15456,6 +16614,109 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jest-changed-files@30.2.0: + dependencies: + execa: 5.1.1 + jest-util: 30.2.0 + p-limit: 3.1.0 + + jest-circus@30.2.0(babel-plugin-macros@3.1.0): + dependencies: + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 22.10.5 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.1(babel-plugin-macros@3.1.0) + is-generator-fn: 2.1.0 + jest-each: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + p-limit: 3.1.0 + pretty-format: 30.2.0 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)): + dependencies: + '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)) + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)) + jest-util: 30.2.0 + jest-validate: 30.2.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-config@30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)): + dependencies: + '@babel/core': 7.28.6 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.6) + chalk: 4.1.2 + ci-info: 4.3.1 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.2.0(babel-plugin-macros@3.1.0) + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.2.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.10.5 + ts-node: 10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@30.2.0: + dependencies: + '@jest/diff-sequences': 30.0.1 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.2.0 + + jest-docblock@30.2.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + jest-util: 30.2.0 + pretty-format: 30.2.0 + jest-environment-node@29.7.0: dependencies: '@jest/environment': 29.7.0 @@ -15465,8 +16726,45 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 + jest-environment-node@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 22.10.5 + jest-mock: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-get-type@29.6.3: {} + jest-haste-map@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 22.10.5 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.0.1 + jest-util: 30.2.0 + jest-worker: 30.2.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + pretty-format: 30.2.0 + + jest-matcher-utils@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.2.0 + pretty-format: 30.2.0 + jest-message-util@29.7.0: dependencies: '@babel/code-frame': 7.28.6 @@ -15479,12 +16777,134 @@ snapshots: slash: 3.0.0 stack-utils: 2.0.6 + jest-message-util@30.2.0: + dependencies: + '@babel/code-frame': 7.28.6 + '@jest/types': 30.2.0 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + stack-utils: 2.0.6 + jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 '@types/node': 22.10.5 jest-util: 29.7.0 + jest-mock@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 22.10.5 + jest-util: 30.2.0 + + jest-pnp-resolver@1.2.3(jest-resolve@30.2.0): + optionalDependencies: + jest-resolve: 30.2.0 + + jest-regex-util@30.0.1: {} + + jest-resolve-dependencies@30.2.0: + dependencies: + jest-regex-util: 30.0.1 + jest-snapshot: 30.2.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@30.2.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-pnp-resolver: 1.2.3(jest-resolve@30.2.0) + jest-util: 30.2.0 + jest-validate: 30.2.0 + slash: 3.0.0 + unrs-resolver: 1.11.1 + + jest-runner@30.2.0: + dependencies: + '@jest/console': 30.2.0 + '@jest/environment': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 22.10.5 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-haste-map: 30.2.0 + jest-leak-detector: 30.2.0 + jest-message-util: 30.2.0 + jest-resolve: 30.2.0 + jest-runtime: 30.2.0 + jest-util: 30.2.0 + jest-watcher: 30.2.0 + jest-worker: 30.2.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/globals': 30.2.0 + '@jest/source-map': 30.0.1 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 22.10.5 + chalk: 4.1.2 + cjs-module-lexer: 2.2.0 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@30.2.0: + dependencies: + '@babel/core': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.6) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.28.6) + '@babel/types': 7.28.6 + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.6) + chalk: 4.1.2 + expect: 30.2.0 + graceful-fs: 4.2.11 + jest-diff: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + pretty-format: 30.2.0 + semver: 7.7.3 + synckit: 0.11.12 + transitivePeerDependencies: + - supports-color + jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 @@ -15494,6 +16914,15 @@ snapshots: graceful-fs: 4.2.11 picomatch: 2.3.1 + jest-util@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 22.10.5 + chalk: 4.1.2 + ci-info: 4.3.1 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + jest-validate@29.7.0: dependencies: '@jest/types': 29.6.3 @@ -15503,6 +16932,26 @@ snapshots: leven: 3.1.0 pretty-format: 29.7.0 + jest-validate@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.2.0 + + jest-watcher@30.2.0: + dependencies: + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 22.10.5 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.2.0 + string-length: 4.0.2 + jest-worker@29.7.0: dependencies: '@types/node': 22.10.5 @@ -15510,6 +16959,27 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jest-worker@30.2.0: + dependencies: + '@types/node': 22.10.5 + '@ungap/structured-clone': 1.3.0 + jest-util: 30.2.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)): + dependencies: + '@jest/core': 30.2.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)) + '@jest/types': 30.2.0 + import-local: 3.2.0 + jest-cli: 30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jiti@1.21.6: optional: true @@ -15523,6 +16993,8 @@ snapshots: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 + jose@6.1.3: {} + js-stringify@1.0.2: {} js-tokens@4.0.0: {} @@ -15815,6 +17287,8 @@ snapshots: lodash.map@4.6.0: {} + lodash.memoize@4.1.2: {} + lodash.merge@4.6.2: {} lodash.mergewith@4.6.2: {} @@ -15918,6 +17392,10 @@ snapshots: pify: 4.0.1 semver: 5.7.2 + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + make-error@1.3.6: {} make-fetch-happen@10.2.1: @@ -16527,6 +18005,8 @@ snapshots: napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -16638,6 +18118,15 @@ snapshots: node-stream-zip@1.15.0: {} + node-test-github-reporter@1.3.1: + dependencies: + '@actions/core': 1.11.1 + error-stack-parser: 2.1.4 + node-test-parser: 3.1.0 + stack-utils: 2.0.6 + + node-test-parser@3.1.0: {} + nodemailer@6.10.0: {} nodemailer@6.9.16: @@ -16714,6 +18203,8 @@ snapshots: nullthrows@1.1.1: {} + oauth4webapi@3.8.4: {} + ob1@0.80.12: dependencies: flow-enums-runtime: 0.0.6 @@ -16803,6 +18294,11 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openid-client@6.8.2: + dependencies: + jose: 6.1.3 + oauth4webapi: 3.8.4 + openpgp@6.3.0: {} optionator@0.9.4: @@ -17001,6 +18497,10 @@ snapshots: dependencies: find-up: 3.0.0 + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + plimit-lit@1.6.1: dependencies: queue-lit: 1.5.2 @@ -17115,6 +18615,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-format@30.2.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + preview-email@3.1.0: dependencies: ci-info: 3.9.0 @@ -17252,6 +18758,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@7.0.1: {} + q@1.5.1: {} qs@6.13.0: @@ -17698,6 +19206,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + regexparam@3.0.0: {} + regexpu-core@6.4.0: dependencies: regenerate: 1.4.2 @@ -17740,6 +19250,10 @@ snapshots: resize-observer-polyfill@1.5.1: {} + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + resolve-dir@1.0.1: dependencies: expand-tilde: 2.0.2 @@ -18116,6 +19630,11 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -18217,6 +19736,11 @@ snapshots: string-argv@0.3.2: {} + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -18348,6 +19872,28 @@ snapshots: sudo-prompt@9.2.1: {} + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3(supports-color@5.5.0) + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.1 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -18391,6 +19937,10 @@ snapshots: react: 18.3.1 use-sync-external-store: 1.6.0(react@18.3.1) + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + tailwind-merge@2.6.0: {} tailwindcss@3.4.19(yaml@2.8.2): @@ -18460,6 +20010,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + text-extensions@1.9.0: {} text-hex@1.0.0: {} @@ -18562,6 +20118,26 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)))(typescript@5.4.5): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 30.2.0(@types/node@22.10.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@5.4.5)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.4.5 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.28.6 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.6) + jest-util: 30.2.0 + ts-node@10.9.2(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.5.1)(typescript@5.4.5): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -18642,6 +20218,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + tunnel@0.0.6: {} + tweetnacl@0.14.5: {} type-check@0.4.0: @@ -18662,6 +20240,8 @@ snapshots: type-fest@0.8.1: {} + type-fest@4.41.0: {} + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -18762,6 +20342,10 @@ snapshots: undici-types@6.20.0: {} + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + undici@7.18.2: {} unicode-canonical-property-names-ecmascript@2.0.0: {} @@ -18838,6 +20422,30 @@ snapshots: unpipe@1.0.0: {} + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + untildify@4.0.0: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -18880,6 +20488,12 @@ snapshots: v8-compile-cache-lib@3.0.1: {} + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + valid-data-url@3.0.1: {} validate-npm-package-license@3.0.4: @@ -19060,8 +20674,7 @@ snapshots: word-wrap@1.2.5: {} - wordwrap@1.0.0: - optional: true + wordwrap@1.0.0: {} wrap-ansi@6.2.0: dependencies: @@ -19089,6 +20702,11 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 3.0.7 + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + ws@6.2.3: dependencies: async-limiter: 1.0.1 diff --git a/seerr-api.yml b/seerr-api.yml index aaaf30bf4a..d5d4a73641 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -254,6 +254,64 @@ components: enableSpecialEpisodes: type: boolean example: false + OidcProvider: + type: object + properties: + slug: + type: string + readOnly: true + name: + type: string + issuerUrl: + type: string + clientId: + type: string + clientSecret: + type: string + logo: + type: string + requiredClaims: + type: string + scopes: + type: string + newUserLogin: + type: boolean + required: + - slug + - name + - issuerUrl + - clientId + - clientSecret + PublicOidcProvider: + type: object + readOnly: true + properties: + slug: + type: string + name: + type: string + logo: + type: string + required: + - slug + - name + - logo + OidcSettings: + type: object + properties: + providers: + type: array + items: + $ref: '#/components/schemas/OidcProvider' + LinkedAccount: + type: object + properties: + id: + type: number + username: + type: string + provider: + $ref: '#/components/schemas/PublicOidcProvider' NetworkSettings: type: object properties: @@ -2215,6 +2273,64 @@ paths: application/json: schema: $ref: '#/components/schemas/MainSettings' + /settings/oidc: + get: + summary: Get OpenID Connect settings + description: Retrieves all OpenID Connect settings in a JSON object. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/OidcSettings' + /settings/oidc/{provider}: + put: + summary: Update OpenID Connect provider + description: Updates an existing OpenID Connect provider with the provided values. + tags: + - settings + parameters: + - in: path + name: provider + required: true + schema: + type: string + description: Provider slug + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OidcProvider' + responses: + '200': + description: 'Radarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + delete: + summary: Delete OpenID Connect provider + description: Deletes an existing OpenID Connect provider based on the provider slug parameter. + tags: + - settings + parameters: + - in: path + name: provider + required: true + schema: + type: string + description: Provider slug + responses: + '200': + description: 'OpenID Connect provider deleted' + content: + application/json: + schema: + $ref: '#/components/schemas/OidcSettings' /settings/network: get: summary: Get network settings @@ -4012,6 +4128,69 @@ paths: required: - email - password + /auth/oidc/login/{slug}: + get: + summary: Initiate OpenID Connect login + description: Initiates the OpenID Connect authorization code flow with PKCE for the specified provider. Returns a redirect URL to the provider's authorization endpoint. + security: [] + tags: + - auth + parameters: + - in: path + name: slug + required: true + schema: + type: string + description: Provider slug + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + redirectUrl: + type: string + '403': + description: OpenID Connect sign-in is disabled or provider not found + /auth/oidc/callback/{slug}: + get: + summary: Handle OpenID Connect callback + description: Handles the authorization code callback from the OpenID Connect provider. Exchanges the code for tokens, validates claims, and either logs in an existing user, links the account to the currently logged-in user, or creates a new user if allowed. + security: [] + tags: + - auth + x-allow-unknown-query-parameters: true + parameters: + - in: path + name: slug + required: true + schema: + type: string + description: Provider slug + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + to: + type: string + example: '/' + '400': + description: Invalid token response or unable to create new user account + '403': + description: OpenID Connect sign-in is disabled or insufficient permissions + '409': + description: A user with this email address already exists + '500': + description: An unknown error occurred /auth/logout: post: summary: Sign out and clear session cookie @@ -4935,6 +5114,29 @@ paths: responses: '204': description: User password updated + /user/{userId}/settings/linked-accounts: + get: + summary: Lists the user's linked OpenID Connect accounts + description: Lists the user's linked OpenID Connect accounts + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: List of linked accounts + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LinkedAccount' + '403': + description: Invalid credentials /user/{userId}/settings/linked-accounts/plex: post: summary: Link the provided Plex account to the current user @@ -5033,6 +5235,28 @@ paths: description: Unlink request invalid '404': description: User does not exist + /user/{userId}/settings/linked-accounts/{acctId}: + delete: + summary: Remove a linked account for a user + description: Removes the linked account with the given ID for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + - in: path + name: acctId + required: true + schema: + type: number + responses: + '204': + description: Unlinking account succeeded + '404': + description: User or linked account does not exist /user/{userId}/settings/notifications: get: summary: Get notification settings for a user diff --git a/server/datasource.ts b/server/datasource.ts index d474658c1c..0e3126f84b 100644 --- a/server/datasource.ts +++ b/server/datasource.ts @@ -38,6 +38,17 @@ function buildSslConfig(): TlsOptions | undefined { }; } +const testConfig: DataSourceOptions = { + type: 'sqlite', + database: ':memory:', + synchronize: true, + dropSchema: true, + logging: boolFromEnv('DB_LOG_QUERIES'), + entities: ['server/entity/**/*.ts'], + migrations: ['server/migration/sqlite/**/*.ts'], + subscribers: ['server/subscriber/**/*.ts'], +}; + const devConfig: DataSourceOptions = { type: 'sqlite', database: process.env.CONFIG_DIRECTORY @@ -105,7 +116,9 @@ const postgresProdConfig: DataSourceOptions = { export const isPgsql = process.env.DB_TYPE === 'postgres'; function getDataSource(): DataSourceOptions { - if (process.env.NODE_ENV === 'production') { + if (process.env.NODE_ENV === 'test') { + return testConfig; + } else if (process.env.NODE_ENV === 'production') { return isPgsql ? postgresProdConfig : prodConfig; } else { return isPgsql ? postgresDevConfig : devConfig; diff --git a/server/entity/LinkedAccount.ts b/server/entity/LinkedAccount.ts new file mode 100644 index 0000000000..fba4edbc8a --- /dev/null +++ b/server/entity/LinkedAccount.ts @@ -0,0 +1,27 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { User } from './User'; + +@Entity('linked_accounts') +export class LinkedAccount { + constructor(options: Omit) { + Object.assign(this, options); + } + + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => User, (user) => user.linkedAccounts, { onDelete: 'CASCADE' }) + user: User; + + /** Slug of the OIDC provider. */ + @Column({ type: 'varchar', length: 255 }) + provider: string; + + /** Unique ID from the OAuth provider */ + @Column({ type: 'varchar', length: 255 }) + sub: string; + + /** Account username from the OAuth provider */ + @Column() + username: string; +} diff --git a/server/entity/User.ts b/server/entity/User.ts index b15b6683a7..60a5ff2cb8 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -25,6 +25,7 @@ import { RelationCount, } from 'typeorm'; import Issue from './Issue'; +import { LinkedAccount } from './LinkedAccount'; import { MediaRequest } from './MediaRequest'; import SeasonRequest from './SeasonRequest'; import { UserPushSubscription } from './UserPushSubscription'; @@ -39,7 +40,7 @@ export class User { return users.map((u) => u.filter(showFiltered)); } - static readonly filteredFields: string[] = ['email', 'plexId']; + static readonly filteredFields: string[] = ['email', 'plexId', 'password']; public displayName: string; @@ -70,7 +71,7 @@ export class User { @Column({ nullable: true, select: false }) public resetPasswordGuid?: string; - @Column({ type: 'date', nullable: true }) + @DbAwareColumn({ type: 'datetime', nullable: true }) public recoveryLinkExpirationDate?: Date | null; @Column({ type: 'integer', default: UserType.PLEX }) @@ -91,6 +92,9 @@ export class User { @Column({ type: 'varchar', nullable: true, select: false }) public plexToken?: string | null; + @OneToMany(() => LinkedAccount, (link) => link.user) + public linkedAccounts: LinkedAccount[]; + @Column({ type: 'integer', default: 0 }) public permissions = 0; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index ea08d4e61d..5e64c53d15 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -1,3 +1,4 @@ +import type { PublicOidcProvider } from '@server/lib/settings'; import type { DnsEntries, DnsStats } from 'dns-caching'; import type { PaginatedResponse } from './common'; @@ -48,6 +49,7 @@ export interface PublicSettingsResponse { emailEnabled: boolean; newPlexLogin: boolean; youtubeUrl: string; + openIdProviders: PublicOidcProvider[]; } export interface CacheItem { diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 327764618e..40e82ba3d8 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -1,4 +1,7 @@ -import type { NotificationAgentKey } from '@server/lib/settings'; +import type { + NotificationAgentKey, + PublicOidcProvider, +} from '@server/lib/settings'; export interface UserSettingsGeneralResponse { username?: string; @@ -39,3 +42,11 @@ export interface UserSettingsNotificationsResponse { webPushEnabled?: boolean; notificationTypes: Partial; } + +export type UserSettingsLinkedAccount = { + id: number; + username: string; + provider: PublicOidcProvider; +}; + +export type UserSettingsLinkedAccountResponse = UserSettingsLinkedAccount[]; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 0aa9da1596..3a4d43ddc4 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -49,6 +49,25 @@ export interface JellyfinSettings { serverId: string; apiKey: string; } + +export type OidcProvider = { + slug: string; + name: string; + issuerUrl: string; + clientId: string; + clientSecret: string; + logo?: string; + requiredClaims?: string; + scopes?: string; + newUserLogin?: boolean; +}; + +export type PublicOidcProvider = Pick; + +export interface OidcSettings { + providers: OidcProvider[]; +} + export interface TautulliSettings { hostname?: string; port?: number; @@ -135,6 +154,7 @@ export interface MainSettings { hideBlocklisted: boolean; localLogin: boolean; mediaServerLogin: boolean; + oidcLogin: boolean; newPlexLogin: boolean; discoverRegion: string; streamingRegion: string; @@ -203,6 +223,7 @@ interface FullPublicSettings extends PublicSettings { userEmailRequired: boolean; newPlexLogin: boolean; youtubeUrl: string; + openIdProviders: PublicOidcProvider[]; } export interface NotificationAgentConfig { @@ -355,6 +376,7 @@ export interface AllSettings { main: MainSettings; plex: PlexSettings; jellyfin: JellyfinSettings; + oidc: OidcSettings; tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; @@ -370,10 +392,272 @@ const SETTINGS_PATH = process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/settings.json` : path.join(__dirname, '../../../config/settings.json'); +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; + class Settings { private data: AllSettings; - constructor(initialSettings?: AllSettings) { + constructor(initialSettings?: DeepPartial) { + this.reset(); + if (initialSettings) this.load(initialSettings); + } + + get main(): MainSettings { + return this.data.main; + } + + set main(data: MainSettings) { + this.data.main = data; + } + + get plex(): PlexSettings { + return this.data.plex; + } + + set plex(data: PlexSettings) { + this.data.plex = data; + } + + get jellyfin(): JellyfinSettings { + return this.data.jellyfin; + } + + set jellyfin(data: JellyfinSettings) { + this.data.jellyfin = data; + } + + get oidc(): OidcSettings { + return this.data.oidc; + } + + set oidc(data: OidcSettings) { + this.data.oidc = data; + } + + get tautulli(): TautulliSettings { + return this.data.tautulli; + } + + set tautulli(data: TautulliSettings) { + this.data.tautulli = data; + } + + get metadataSettings(): MetadataSettings { + return this.data.metadataSettings; + } + + set metadataSettings(data: MetadataSettings) { + this.data.metadataSettings = data; + } + + get radarr(): RadarrSettings[] { + return this.data.radarr; + } + + set radarr(data: RadarrSettings[]) { + this.data.radarr = data; + } + + get sonarr(): SonarrSettings[] { + return this.data.sonarr; + } + + set sonarr(data: SonarrSettings[]) { + this.data.sonarr = data; + } + + get public(): PublicSettings { + return this.data.public; + } + + set public(data: PublicSettings) { + this.data.public = data; + } + + get fullPublicSettings(): FullPublicSettings { + return { + ...this.data.public, + applicationTitle: this.data.main.applicationTitle, + applicationUrl: this.data.main.applicationUrl, + hideAvailable: this.data.main.hideAvailable, + hideBlocklisted: this.data.main.hideBlocklisted, + localLogin: this.data.main.localLogin, + mediaServerLogin: this.data.main.mediaServerLogin, + jellyfinExternalHost: this.data.jellyfin.externalHostname, + jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl, + movie4kEnabled: this.data.radarr.some( + (radarr) => radarr.is4k && radarr.isDefault + ), + series4kEnabled: this.data.sonarr.some( + (sonarr) => sonarr.is4k && sonarr.isDefault + ), + discoverRegion: this.data.main.discoverRegion, + streamingRegion: this.data.main.streamingRegion, + originalLanguage: this.data.main.originalLanguage, + mediaServerType: this.main.mediaServerType, + partialRequestsEnabled: this.data.main.partialRequestsEnabled, + enableSpecialEpisodes: this.data.main.enableSpecialEpisodes, + cacheImages: this.data.main.cacheImages, + vapidPublic: this.vapidPublic, + enablePushRegistration: this.data.notifications.agents.webpush.enabled, + locale: this.data.main.locale, + emailEnabled: this.data.notifications.agents.email.enabled, + userEmailRequired: + this.data.notifications.agents.email.options.userEmailRequired, + newPlexLogin: this.data.main.newPlexLogin, + youtubeUrl: this.data.main.youtubeUrl, + openIdProviders: this.data.main.oidcLogin + ? this.data.oidc.providers.map((p) => ({ + slug: p.slug, + name: p.name, + logo: p.logo, + })) + : [], + }; + } + + get notifications(): NotificationSettings { + return this.data.notifications; + } + + set notifications(data: NotificationSettings) { + this.data.notifications = data; + } + + get jobs(): Record { + return this.data.jobs; + } + + set jobs(data: Record) { + this.data.jobs = data; + } + + get network(): NetworkSettings { + return this.data.network; + } + + set network(data: NetworkSettings) { + this.data.network = data; + } + + get migrations(): string[] { + return this.data.migrations; + } + + set migrations(data: string[]) { + this.data.migrations = data; + } + + get clientId(): string { + return this.data.clientId; + } + + get vapidPublic(): string { + return this.data.vapidPublic; + } + + get vapidPrivate(): string { + return this.data.vapidPrivate; + } + + public async regenerateApiKey(): Promise { + this.main.apiKey = this.generateApiKey(); + await this.save(); + return this.main; + } + + private generateApiKey(): string { + if (process.env.API_KEY) { + return process.env.API_KEY; + } else { + return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64'); + } + } + + /** + * Settings Load + * + * This will load settings from file unless an optional argument of the object structure + * is passed in. + * @param overrideSettings If passed in, will override all existing settings with these + * @param raw If true, will load the settings without running migrations or generating missing + * values + */ + public async load( + overrideSettings?: DeepPartial, + raw?: false + ): Promise; + public async load( + overrideSettings: AllSettings | undefined, + raw: true + ): Promise; + public async load( + overrideSettings?: DeepPartial, + raw = false + ): Promise { + if (overrideSettings) { + if (raw) { + this.data = overrideSettings as AllSettings; + } else { + this.data = merge(this.data, overrideSettings); + } + return this; + } + + let data; + try { + data = await fs.readFile(SETTINGS_PATH, 'utf-8'); + } catch { + await this.save(); + } + + if (data && !raw) { + const parsedJson = JSON.parse(data); + const migratedData = await runMigrations(parsedJson, SETTINGS_PATH); + this.data = merge(this.data, migratedData); + } else if (data) { + this.data = JSON.parse(data); + } + + // generate keys and ids if it's missing + let change = false; + if (!this.data.main.apiKey) { + this.data.main.apiKey = this.generateApiKey(); + change = true; + } else if (process.env.API_KEY) { + if (this.main.apiKey != process.env.API_KEY) { + this.main.apiKey = process.env.API_KEY; + } + } + if (!this.data.clientId) { + this.data.clientId = randomUUID(); + change = true; + } + if (!this.data.vapidPublic || !this.data.vapidPrivate) { + const vapidKeys = webpush.generateVAPIDKeys(); + this.data.vapidPrivate = vapidKeys.privateKey; + this.data.vapidPublic = vapidKeys.publicKey; + change = true; + } + if (change) { + await this.save(); + } + + return this; + } + + public async save(): Promise { + const tmp = SETTINGS_PATH + '.tmp'; + await fs.writeFile(tmp, JSON.stringify(this.data, undefined, ' ')); + await fs.rename(tmp, SETTINGS_PATH); + } + + /** + * Resets all settings to their default values. + */ + public reset() { this.data = { clientId: randomUUID(), vapidPrivate: '', @@ -392,6 +676,7 @@ class Settings { hideBlocklisted: false, localLogin: true, mediaServerLogin: true, + oidcLogin: false, newPlexLogin: true, discoverRegion: '', streamingRegion: '', @@ -423,6 +708,9 @@ class Settings { serverId: '', apiKey: '', }, + oidc: { + providers: [], + }, tautulli: {}, metadataSettings: { tv: MetadataProviderType.TMDB, @@ -596,232 +884,6 @@ class Settings { }, migrations: [], }; - if (initialSettings) { - this.data = merge(this.data, initialSettings); - } - } - - get main(): MainSettings { - return this.data.main; - } - - set main(data: MainSettings) { - this.data.main = data; - } - - get plex(): PlexSettings { - return this.data.plex; - } - - set plex(data: PlexSettings) { - this.data.plex = data; - } - - get jellyfin(): JellyfinSettings { - return this.data.jellyfin; - } - - set jellyfin(data: JellyfinSettings) { - this.data.jellyfin = data; - } - - get tautulli(): TautulliSettings { - return this.data.tautulli; - } - - set tautulli(data: TautulliSettings) { - this.data.tautulli = data; - } - - get metadataSettings(): MetadataSettings { - return this.data.metadataSettings; - } - - set metadataSettings(data: MetadataSettings) { - this.data.metadataSettings = data; - } - - get radarr(): RadarrSettings[] { - return this.data.radarr; - } - - set radarr(data: RadarrSettings[]) { - this.data.radarr = data; - } - - get sonarr(): SonarrSettings[] { - return this.data.sonarr; - } - - set sonarr(data: SonarrSettings[]) { - this.data.sonarr = data; - } - - get public(): PublicSettings { - return this.data.public; - } - - set public(data: PublicSettings) { - this.data.public = data; - } - - get fullPublicSettings(): FullPublicSettings { - return { - ...this.data.public, - applicationTitle: this.data.main.applicationTitle, - applicationUrl: this.data.main.applicationUrl, - hideAvailable: this.data.main.hideAvailable, - hideBlocklisted: this.data.main.hideBlocklisted, - localLogin: this.data.main.localLogin, - mediaServerLogin: this.data.main.mediaServerLogin, - jellyfinExternalHost: this.data.jellyfin.externalHostname, - jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl, - movie4kEnabled: this.data.radarr.some( - (radarr) => radarr.is4k && radarr.isDefault - ), - series4kEnabled: this.data.sonarr.some( - (sonarr) => sonarr.is4k && sonarr.isDefault - ), - discoverRegion: this.data.main.discoverRegion, - streamingRegion: this.data.main.streamingRegion, - originalLanguage: this.data.main.originalLanguage, - mediaServerType: this.main.mediaServerType, - partialRequestsEnabled: this.data.main.partialRequestsEnabled, - enableSpecialEpisodes: this.data.main.enableSpecialEpisodes, - cacheImages: this.data.main.cacheImages, - vapidPublic: this.vapidPublic, - enablePushRegistration: this.data.notifications.agents.webpush.enabled, - locale: this.data.main.locale, - emailEnabled: this.data.notifications.agents.email.enabled, - userEmailRequired: - this.data.notifications.agents.email.options.userEmailRequired, - newPlexLogin: this.data.main.newPlexLogin, - youtubeUrl: this.data.main.youtubeUrl, - }; - } - - get notifications(): NotificationSettings { - return this.data.notifications; - } - - set notifications(data: NotificationSettings) { - this.data.notifications = data; - } - - get jobs(): Record { - return this.data.jobs; - } - - set jobs(data: Record) { - this.data.jobs = data; - } - - get network(): NetworkSettings { - return this.data.network; - } - - set network(data: NetworkSettings) { - this.data.network = data; - } - - get migrations(): string[] { - return this.data.migrations; - } - - set migrations(data: string[]) { - this.data.migrations = data; - } - - get clientId(): string { - return this.data.clientId; - } - - get vapidPublic(): string { - return this.data.vapidPublic; - } - - get vapidPrivate(): string { - return this.data.vapidPrivate; - } - - public async regenerateApiKey(): Promise { - this.main.apiKey = this.generateApiKey(); - await this.save(); - return this.main; - } - - private generateApiKey(): string { - if (process.env.API_KEY) { - return process.env.API_KEY; - } else { - return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64'); - } - } - - /** - * Settings Load - * - * This will load settings from file unless an optional argument of the object structure - * is passed in. - * @param overrideSettings If passed in, will override all existing settings with these - * @param raw If true, will load the settings without running migrations or generating missing - * values - */ - public async load( - overrideSettings?: AllSettings, - raw = false - ): Promise { - if (overrideSettings) { - this.data = overrideSettings; - return this; - } - - let data; - try { - data = await fs.readFile(SETTINGS_PATH, 'utf-8'); - } catch { - await this.save(); - } - - if (data && !raw) { - const parsedJson = JSON.parse(data); - const migratedData = await runMigrations(parsedJson, SETTINGS_PATH); - this.data = merge(this.data, migratedData); - } else if (data) { - this.data = JSON.parse(data); - } - - // generate keys and ids if it's missing - let change = false; - if (!this.data.main.apiKey) { - this.data.main.apiKey = this.generateApiKey(); - change = true; - } else if (process.env.API_KEY) { - if (this.main.apiKey != process.env.API_KEY) { - this.main.apiKey = process.env.API_KEY; - } - } - if (!this.data.clientId) { - this.data.clientId = randomUUID(); - change = true; - } - if (!this.data.vapidPublic || !this.data.vapidPrivate) { - const vapidKeys = webpush.generateVAPIDKeys(); - this.data.vapidPrivate = vapidKeys.privateKey; - this.data.vapidPublic = vapidKeys.publicKey; - change = true; - } - if (change) { - await this.save(); - } - - return this; - } - - public async save(): Promise { - const tmp = SETTINGS_PATH + '.tmp'; - await fs.writeFile(tmp, JSON.stringify(this.data, undefined, ' ')); - await fs.rename(tmp, SETTINGS_PATH); } } diff --git a/server/migration/postgres/1742858617989-AddLinkedAccount.ts b/server/migration/postgres/1742858617989-AddLinkedAccount.ts new file mode 100644 index 0000000000..c24acc3888 --- /dev/null +++ b/server/migration/postgres/1742858617989-AddLinkedAccount.ts @@ -0,0 +1,21 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLinkedAccount1742858617989 implements MigrationInterface { + name = 'AddLinkedAccount1742858617989'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "linked_accounts" ("id" SERIAL NOT NULL, "provider" character varying(255) NOT NULL, "sub" character varying(255) NOT NULL, "username" character varying NOT NULL, "userId" integer, CONSTRAINT "PK_445bf7a50aeeb7f0084052935a6" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `ALTER TABLE "linked_accounts" ADD CONSTRAINT "FK_2c77d2a0c06eeab6e62dc35af64" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "linked_accounts" DROP CONSTRAINT "FK_2c77d2a0c06eeab6e62dc35af64"` + ); + await queryRunner.query(`DROP TABLE "linked_accounts"`); + } +} diff --git a/server/migration/postgres/1771337333450-RecoveryLinkExpirationDateTime.ts b/server/migration/postgres/1771337333450-RecoveryLinkExpirationDateTime.ts new file mode 100644 index 0000000000..8e4c9e803d --- /dev/null +++ b/server/migration/postgres/1771337333450-RecoveryLinkExpirationDateTime.ts @@ -0,0 +1,17 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RecoveryLinkExpirationDateTime1771337333450 implements MigrationInterface { + name = 'RecoveryLinkExpirationDateTime1771337333450'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ALTER COLUMN "recoveryLinkExpirationDate" TYPE TIMESTAMP WITH TIME ZONE` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ALTER COLUMN "recoveryLinkExpirationDate" TYPE date USING ("recoveryLinkExpirationDate"::date)` + ); + } +} diff --git a/server/migration/sqlite/1742858484395-AddLinkedAccounts.ts b/server/migration/sqlite/1742858484395-AddLinkedAccounts.ts new file mode 100644 index 0000000000..6161394fee --- /dev/null +++ b/server/migration/sqlite/1742858484395-AddLinkedAccounts.ts @@ -0,0 +1,15 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLinkedAccounts1742858484395 implements MigrationInterface { + name = 'AddLinkedAccounts1742858484395'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "linked_accounts" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "provider" varchar(255) NOT NULL, "sub" varchar(255) NOT NULL, "username" varchar NOT NULL, "userId" integer, CONSTRAINT "FK_2c77d2a0c06eeab6e62dc35af64" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "linked_accounts"`); + } +} diff --git a/server/migration/sqlite/1771337037917-RecoveryLinkExpirationDateTime.ts b/server/migration/sqlite/1771337037917-RecoveryLinkExpirationDateTime.ts new file mode 100644 index 0000000000..3e40cdfc4b --- /dev/null +++ b/server/migration/sqlite/1771337037917-RecoveryLinkExpirationDateTime.ts @@ -0,0 +1,27 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RecoveryLinkExpirationDateTime1771337037917 implements MigrationInterface { + name = 'RecoveryLinkExpirationDateTime1771337037917'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" datetime, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "user"` + ); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); + await queryRunner.query( + `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + } +} diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts new file mode 100644 index 0000000000..30cce71740 --- /dev/null +++ b/server/routes/auth.test.ts @@ -0,0 +1,718 @@ +import assert from 'node:assert/strict'; +import { + after, + afterEach, + before, + beforeEach, + describe, + it, + mock, +} from 'node:test'; + +import { getRepository } from '@server/datasource'; +import { LinkedAccount } from '@server/entity/LinkedAccount'; +import { User } from '@server/entity/User'; +import { getSettings } from '@server/lib/settings'; +import { checkUser } from '@server/middleware/auth'; +import { setupTestDb } from '@server/test/db'; +import cookieParser from 'cookie-parser'; +import type { Express } from 'express'; +import express from 'express'; +import session from 'express-session'; +import fetchMock from 'fetch-mock'; +import request from 'supertest'; +import authRoutes from './auth'; + +// Mock email sending to prevent SMTP connection attempts +mock.module('../lib/email/index.ts', { + defaultExport: () => ({ + send: mock.fn(async () => undefined), + }), +}); + +let app: Express; + +function createApp() { + const app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use( + session({ + secret: 'test-secret', + resave: false, + saveUninitialized: false, + }) + ); + app.use(checkUser); + app.use('/auth', authRoutes); + // Error handler matching how next({ status, message }) calls are handled + app.use( + ( + err: { status?: number; message?: string }, + _req: express.Request, + res: express.Response, + // We must provide a next function for the function signature here even though its not used + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _next: express.NextFunction + ) => { + res + .status(err.status ?? 500) + .json({ status: err.status ?? 500, message: err.message }); + } + ); + return app; +} + +before(async () => { + app = createApp(); +}); + +afterEach(() => { + getSettings().reset(); +}); + +setupTestDb(); + +/** Create a supertest agent that is logged in as the given user. */ +async function authenticatedAgent(email: string, password: string) { + const agent = request.agent(app); + const settings = getSettings(); + settings.main.localLogin = true; + + const res = await agent.post('/auth/local').send({ email, password }); + + assert.strictEqual(res.status, 200); + return agent; +} + +describe('GET /auth/me', () => { + it('returns 403 when not authenticated', async () => { + const res = await request(app).get('/auth/me'); + assert.strictEqual(res.status, 403); + }); + + it('returns the authenticated user', async () => { + const agent = await authenticatedAgent('admin@seerr.dev', 'test1234'); + + const res = await agent.get('/auth/me'); + + assert.strictEqual(res.status, 200); + assert.ok('id' in res.body); + assert.strictEqual(res.body.displayName, 'admin'); + }); + + it('includes userEmailRequired warning when email is required but invalid', async () => { + const settings = getSettings(); + settings.notifications.agents.email.options.userEmailRequired = true; + + // Change the user's email to something invalid + const userRepo = getRepository(User); + const user = await userRepo.findOneOrFail({ + where: { email: 'admin@seerr.dev' }, + }); + user.email = 'not-an-email'; + await userRepo.save(user); + + // Log in with the changed email + const agent = request.agent(app); + settings.main.localLogin = true; + const loginRes = await agent + .post('/auth/local') + .send({ email: 'not-an-email', password: 'test1234' }); + assert.strictEqual(loginRes.status, 200); + + const res = await agent.get('/auth/me'); + + assert.strictEqual(res.status, 200); + assert.ok(res.body.warnings.includes('userEmailRequired')); + + settings.notifications.agents.email.options.userEmailRequired = false; + }); +}); + +describe('POST /auth/local', () => { + beforeEach(() => { + const settings = getSettings(); + settings.main.localLogin = true; + }); + + it('returns 200 and user data on valid credentials', async () => { + const res = await request(app) + .post('/auth/local') + .send({ email: 'admin@seerr.dev', password: 'test1234' }); + + assert.strictEqual(res.status, 200); + assert.ok('id' in res.body); + // filter() strips sensitive fields like password + assert.ok(!('password' in res.body)); + }); + + it('returns 403 on wrong password', async () => { + const res = await request(app) + .post('/auth/local') + .send({ email: 'admin@seerr.dev', password: 'wrongpassword' }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.message, 'Access denied.'); + }); + + it('returns 403 for nonexistent user', async () => { + const res = await request(app) + .post('/auth/local') + .send({ email: 'nobody@seerr.dev', password: 'test1234' }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.message, 'Access denied.'); + }); + + it('returns 500 when local login is disabled', async () => { + const settings = getSettings(); + settings.main.localLogin = false; + + const res = await request(app) + .post('/auth/local') + .send({ email: 'admin@seerr.dev', password: 'test1234' }); + + assert.strictEqual(res.status, 500); + assert.strictEqual(res.body.error, 'Password sign-in is disabled.'); + }); + + it('returns 500 when email is missing', async () => { + const res = await request(app) + .post('/auth/local') + .send({ password: 'test1234' }); + + assert.strictEqual(res.status, 500); + assert.match(res.body.error, /email address and a password/); + }); + + it('returns 500 when password is missing', async () => { + const res = await request(app) + .post('/auth/local') + .send({ email: 'admin@seerr.dev' }); + + assert.strictEqual(res.status, 500); + assert.match(res.body.error, /email address and a password/); + }); + + it('is case-insensitive for email', async () => { + const res = await request(app) + .post('/auth/local') + .send({ email: 'Admin@Seerr.Dev', password: 'test1234' }); + + assert.strictEqual(res.status, 200); + assert.ok('id' in res.body); + }); + + it('allows the non-admin user to log in', async () => { + const res = await request(app) + .post('/auth/local') + .send({ email: 'friend@seerr.dev', password: 'test1234' }); + + assert.strictEqual(res.status, 200); + assert.ok('id' in res.body); + }); + + it('sets a session on successful login', async () => { + const agent = request.agent(app); + + await agent + .post('/auth/local') + .send({ email: 'admin@seerr.dev', password: 'test1234' }); + + // Session should persist — /me should succeed + const meRes = await agent.get('/auth/me'); + assert.strictEqual(meRes.status, 200); + }); +}); + +describe('POST /auth/logout', () => { + it('returns 200 when not logged in', async () => { + const res = await request(app).post('/auth/logout'); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.status, 'ok'); + }); + + it('destroys session and returns 200 when logged in', async () => { + const agent = await authenticatedAgent('admin@seerr.dev', 'test1234'); + + // Verify session is active + const meBeforeRes = await agent.get('/auth/me'); + assert.strictEqual(meBeforeRes.status, 200); + + const logoutRes = await agent.post('/auth/logout'); + assert.strictEqual(logoutRes.status, 200); + assert.strictEqual(logoutRes.body.status, 'ok'); + + // Session should be invalidated — /me should fail + const meAfterRes = await agent.get('/auth/me'); + assert.strictEqual(meAfterRes.status, 403); + }); +}); + +describe('POST /auth/reset-password', () => { + it('returns 200 for a valid email', async () => { + const res = await request(app) + .post('/auth/reset-password') + .send({ email: 'admin@seerr.dev' }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.status, 'ok'); + }); + + it('returns 200 for nonexistent email (does not reveal user existence)', async () => { + const res = await request(app) + .post('/auth/reset-password') + .send({ email: 'nonexistent@seerr.dev' }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.status, 'ok'); + }); + + it('returns 500 when email is missing', async () => { + const res = await request(app).post('/auth/reset-password').send({}); + + assert.strictEqual(res.status, 500); + assert.strictEqual(res.body.message, 'Email address required.'); + }); + + it('sets a resetPasswordGuid on the user', async () => { + await request(app) + .post('/auth/reset-password') + .send({ email: 'admin@seerr.dev' }); + + const userRepo = getRepository(User); + const user = await userRepo + .createQueryBuilder('user') + .addSelect(['user.resetPasswordGuid', 'user.recoveryLinkExpirationDate']) + .where('user.email = :email', { email: 'admin@seerr.dev' }) + .getOneOrFail(); + + assert.notStrictEqual(user.resetPasswordGuid, undefined); + assert.notStrictEqual(user.resetPasswordGuid, null); + assert.notStrictEqual(user.recoveryLinkExpirationDate, undefined); + }); +}); + +describe('POST /auth/reset-password/:guid', () => { + /** Trigger a password reset and return the guid. */ + async function getResetGuid(email: string): Promise { + await request(app).post('/auth/reset-password').send({ email }); + + const userRepo = getRepository(User); + const user = await userRepo + .createQueryBuilder('user') + .addSelect('user.resetPasswordGuid') + .where('user.email = :email', { email }) + .getOneOrFail(); + + return user.resetPasswordGuid!; + } + + it('resets password with a valid guid and password', async () => { + const guid = await getResetGuid('admin@seerr.dev'); + + const res = await request(app) + .post(`/auth/reset-password/${guid}`) + .send({ password: 'newpassword123' }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.status, 'ok'); + + // Old password no longer works + const oldLogin = await request(app) + .post('/auth/local') + .send({ email: 'admin@seerr.dev', password: 'test1234' }); + assert.strictEqual(oldLogin.status, 403); + + // New password works + const newLogin = await request(app) + .post('/auth/local') + .send({ email: 'admin@seerr.dev', password: 'newpassword123' }); + assert.strictEqual(newLogin.status, 200); + }); + + it('returns 500 for an invalid guid', async () => { + const res = await request(app) + .post('/auth/reset-password/invalid-guid-here') + .send({ password: 'newpassword123' }); + + assert.strictEqual(res.status, 500); + assert.strictEqual(res.body.message, 'Invalid password reset link.'); + }); + + it('returns 500 when password is too short', async () => { + const guid = await getResetGuid('admin@seerr.dev'); + + const res = await request(app) + .post(`/auth/reset-password/${guid}`) + .send({ password: 'short' }); + + assert.strictEqual(res.status, 500); + assert.strictEqual( + res.body.message, + 'Password must be at least 8 characters long.' + ); + }); + + it('returns 500 when password is missing', async () => { + const guid = await getResetGuid('admin@seerr.dev'); + + const res = await request(app) + .post(`/auth/reset-password/${guid}`) + .send({}); + + assert.strictEqual(res.status, 500); + assert.strictEqual( + res.body.message, + 'Password must be at least 8 characters long.' + ); + }); + + it('returns 500 for an expired recovery link', async () => { + const guid = await getResetGuid('admin@seerr.dev'); + + // Expire the link + const userRepo = getRepository(User); + const user = await userRepo.findOneOrFail({ + where: { email: 'admin@seerr.dev' }, + }); + user.recoveryLinkExpirationDate = new Date('2020-01-01'); + await userRepo.save(user); + + const res = await request(app) + .post(`/auth/reset-password/${guid}`) + .send({ password: 'newpassword123' }); + + assert.strictEqual(res.status, 500); + assert.strictEqual(res.body.message, 'Invalid password reset link.'); + }); + + it('cannot reuse a guid after successful reset', async () => { + const guid = await getResetGuid('admin@seerr.dev'); + + // First reset succeeds + const first = await request(app) + .post(`/auth/reset-password/${guid}`) + .send({ password: 'newpassword123' }); + assert.strictEqual(first.status, 200); + + // Second reset with same guid fails (recoveryLinkExpirationDate was cleared) + const second = await request(app) + .post(`/auth/reset-password/${guid}`) + .send({ password: 'anotherpassword' }); + assert.strictEqual(second.status, 500); + }); +}); + +describe('OpenID Connect', () => { + // Default claims for new user registration tests + const DEFAULT_CLAIMS = { + sub: 'new-user-sub', + email: 'newuser@example.com', + }; + + // Claims for existing seeded user (friend@seerr.dev) + const EXISTING_USER_CLAIMS = { + sub: 'friend-oidc-sub', + email: 'friend@seerr.dev', + }; + + function buildMockWellKnown(options?: { supportsPKCE?: boolean }) { + return { + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/oauth/authorize', + token_endpoint: 'https://example.com/oauth/token', + userinfo_endpoint: 'https://example.com/userinfo', + jwks_uri: 'https://example.com/.well-known/jwks.json', + response_types_supported: [ + 'code', + 'token', + 'id_token', + 'code token', + 'code id_token', + 'token id_token', + 'code token id_token', + 'none', + ], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + scopes_supported: ['openid', 'email', 'profile'], + ...(options?.supportsPKCE + ? { code_challenge_methods_supported: ['S256'] } + : {}), + }; + } + + /** + * Performs the login + callback flow and returns the callback response. + */ + async function performOidcCallback() { + const loginResponse = await request(app) + .get('/auth/oidc/login/test') + .set('Accept', 'application/json'); + + assert.strictEqual(loginResponse.status, 200); + + const redirectUrl = new URL(loginResponse.body.redirectUrl); + const state = redirectUrl.searchParams.get('state'); + + const cookies = loginResponse.get('Set-Cookie'); + assert.notStrictEqual(cookies, undefined); + const cookieHeader = cookies!.map((c) => c.split(';')[0]).join('; '); + + const callbackQuery = state ? `code=123456&state=${state}` : 'code=123456'; + + const response = await request(app) + .get(`/auth/oidc/callback/test?${callbackQuery}`) + .set('Accept', 'application/json') + .set('Cookie', cookieHeader); + + return response; + } + + let mockJwks: { keys: object[] }; + let signIdToken: (claims?: Record) => Promise; + + before(async () => { + const { generateKeyPair, exportJWK, SignJWT } = await import('jose'); + const { privateKey, publicKey } = await generateKeyPair('RS256'); + const jwk = await exportJWK(publicKey); + jwk.kid = 'test-key'; + jwk.alg = 'RS256'; + jwk.use = 'sig'; + mockJwks = { keys: [jwk] }; + + signIdToken = (claims?: Record) => + new SignJWT({ ...DEFAULT_CLAIMS, ...claims }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer('https://example.com') + .setAudience('jellyseerr') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey); + }); + + beforeEach(() => { + // configure test provider settings + getSettings().load({ + main: { + oidcLogin: true, + applicationUrl: 'https://jellyseerr.example.com', + }, + oidc: { + providers: [ + { + slug: 'test', + name: 'Test Provider', + clientId: 'jellyseerr', + clientSecret: 'abcdefg', + issuerUrl: 'https://example.com', + newUserLogin: true, + }, + ], + }, + }); + }); + + async function setupFetchMock(options?: { + supportsPKCE?: boolean; + userinfoResponse?: Record; + idTokenClaims?: Record; + }) { + const wellKnown = buildMockWellKnown(options); + const userinfo = options?.userinfoResponse ?? DEFAULT_CLAIMS; + const idTokenClaims = options?.idTokenClaims; + const idToken = await signIdToken(idTokenClaims); + const tokenResponse = { + access_token: 'abcdefg', + token_type: 'Bearer', + expires_in: 3600, + id_token: idToken, + }; + + fetchMock.mockGlobal(); + + fetchMock.route( + 'https://example.com/.well-known/openid-configuration', + wellKnown + ); + fetchMock.route('https://example.com/.well-known/jwks.json', mockJwks); + fetchMock.route('https://example.com/oauth/token', tokenResponse); + fetchMock.route('https://example.com/userinfo', userinfo); + } + + describe('without PKCE support (uses state)', function () { + before(async () => { + await setupFetchMock({ supportsPKCE: false }); + }); + + after(() => { + fetchMock.hardReset(); + }); + + it('login endpoint produces correct redirect URL', async function () { + const response = await request(app) + .get('/auth/oidc/login/test') + .set('Accept', 'application/json'); + + assert.match(response.headers['content-type'], /json/); + assert.strictEqual(response.status, 200); + assert.match( + response.body.redirectUrl, + /^https:\/\/example.com\/oauth\/authorize\?/ + ); + + const params = new URL(response.body.redirectUrl); + assert.strictEqual(params.searchParams.get('response_type'), 'code'); + assert.strictEqual(params.searchParams.get('client_id'), 'jellyseerr'); + assert.strictEqual( + params.searchParams.get('scope'), + 'openid profile email' + ); + assert.strictEqual( + params.searchParams.get('redirect_uri'), + 'https://jellyseerr.example.com/login' + ); + assert.ok(params.searchParams.get('state')); + }); + + it('callback endpoint successfully authorizes existing user', async function () { + // Link the seeded friend user to the OIDC provider + const userRepo = getRepository(User); + const linkedAccountRepo = getRepository(LinkedAccount); + + const user = await userRepo.findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + + const linkedAccount = new LinkedAccount({ + user, + provider: 'test', + sub: EXISTING_USER_CLAIMS.sub, + username: 'friend', + }); + await linkedAccountRepo.save(linkedAccount); + + // Setup mock to return the existing user's claims + await setupFetchMock({ + supportsPKCE: false, + idTokenClaims: EXISTING_USER_CLAIMS, + userinfoResponse: EXISTING_USER_CLAIMS, + }); + + const response = await performOidcCallback(); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(response.body, { status: 'ok', to: '/' }); + }); + }); + + describe('with PKCE support (no state)', function () { + before(async () => { + await setupFetchMock({ supportsPKCE: true }); + }); + + after(() => { + fetchMock.hardReset(); + }); + + it('login endpoint does not include state parameter', async function () { + const response = await request(app) + .get('/auth/oidc/login/test') + .set('Accept', 'application/json'); + + assert.strictEqual(response.status, 200); + + const params = new URL(response.body.redirectUrl); + assert.strictEqual(params.searchParams.get('state'), null); + assert.ok(params.searchParams.get('code_challenge')); + assert.strictEqual( + params.searchParams.get('code_challenge_method'), + 'S256' + ); + }); + + it('callback endpoint successfully authorizes existing user', async function () { + // Link the seeded friend user to the OIDC provider + const userRepo = getRepository(User); + const linkedAccountRepo = getRepository(LinkedAccount); + + const user = await userRepo.findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + + const linkedAccount = new LinkedAccount({ + user, + provider: 'test', + sub: EXISTING_USER_CLAIMS.sub, + username: 'friend', + }); + await linkedAccountRepo.save(linkedAccount); + + // Setup mock to return the existing user's claims + await setupFetchMock({ + supportsPKCE: true, + idTokenClaims: EXISTING_USER_CLAIMS, + userinfoResponse: EXISTING_USER_CLAIMS, + }); + + const response = await performOidcCallback(); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(response.body, { status: 'ok', to: '/' }); + }); + }); + + describe('new user registration', function () { + before(async () => { + await setupFetchMock({ supportsPKCE: false }); + }); + + after(() => { + fetchMock.hardReset(); + }); + + it('creates a new user when newUserLogin is enabled', async function () { + const settings = getSettings(); + settings.oidc.providers[0].newUserLogin = true; + + const response = await performOidcCallback(); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(response.body, { status: 'ok', to: '/' }); + + // Verify user was created in the database + const userRepo = getRepository(User); + const createdUser = await userRepo.findOne({ + where: { email: DEFAULT_CLAIMS.email }, + }); + assert.notStrictEqual(createdUser, null); + assert.strictEqual(createdUser!.email, DEFAULT_CLAIMS.email); + + // Verify linked account was created + const linkedAccountRepo = getRepository(LinkedAccount); + const createdLink = await linkedAccountRepo.findOne({ + where: { provider: 'test', sub: DEFAULT_CLAIMS.sub }, + }); + assert.notStrictEqual(createdLink, null); + }); + + it('rejects new user when newUserLogin is disabled', async function () { + const settings = getSettings(); + settings.oidc.providers[0].newUserLogin = false; + + const response = await performOidcCallback(); + + assert.strictEqual(response.status, 400); + + // Verify no new user was created (only seeded users should exist) + const userRepo = getRepository(User); + const newUser = await userRepo.findOne({ + where: { email: DEFAULT_CLAIMS.email }, + }); + assert.strictEqual(newUser, null); + }); + }); +}); diff --git a/server/routes/auth.ts b/server/routes/auth.ts index b74befe5e5..045ca383f3 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -4,6 +4,7 @@ import { ApiErrorCode } from '@server/constants/error'; import { MediaServerType, ServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; +import { LinkedAccount } from '@server/entity/LinkedAccount'; import { User } from '@server/entity/User'; import { startJobs } from '@server/job/schedule'; import { Permission } from '@server/lib/permissions'; @@ -15,8 +16,10 @@ import { ApiError } from '@server/types/error'; import { getAppVersion } from '@server/utils/appVersion'; import { getHostname } from '@server/utils/getHostname'; import axios from 'axios'; -import { Router } from 'express'; +import { Router, type Request } from 'express'; +import gravatarUrl from 'gravatar-url'; import net from 'net'; +import * as openIdClient from 'openid-client'; import validator from 'validator'; const authRoutes = Router(); @@ -646,6 +649,265 @@ authRoutes.post('/local', async (req, res, next) => { } }); +function getOidcRedirectUrl(req: Request) { + const settings = getSettings(); + + const callbackUrl = new URL( + `/login`, + settings.main.applicationUrl || `${req.protocol}://${req.headers.host}` + ); + return callbackUrl; +} + +authRoutes.get('/oidc/login/:slug', async (req, res, next) => { + const settings = getSettings(); + const provider = settings.oidc.providers.find( + (p) => p.slug === req.params.slug + ); + + if (!settings.main.oidcLogin || !provider) { + return next({ + status: 403, + message: 'OpenID Connect sign-in is disabled.', + }); + } + + const config = await openIdClient.discovery( + new URL(provider.issuerUrl), + provider.clientId, + provider.clientSecret + ); + + const code_verifier = openIdClient.randomPKCECodeVerifier(); + const code_challenge = + await openIdClient.calculatePKCECodeChallenge(code_verifier); + res.cookie('oidc-code-verifier', code_verifier, { + maxAge: 60000, + httpOnly: true, + secure: req.protocol === 'https', + }); + + const callbackUrl = getOidcRedirectUrl(req); + + const parameters: Record = { + redirect_uri: callbackUrl.toString(), + scope: provider.scopes ?? 'openid profile email', + code_challenge, + code_challenge_method: 'S256', + }; + + /** + * We cannot be sure the server supports PKCE so we're going to use state too. + * Use of PKCE is backwards compatible even if the AS doesn't support it which + * is why we're using it regardless. Like PKCE, random state must be generated + * for every redirect to the authorization_endpoint. + */ + if (!config.serverMetadata().supportsPKCE()) { + const state = openIdClient.randomState(); + parameters.state = state; + res.cookie('oidc-state', state, { + maxAge: 60000, + httpOnly: true, + secure: req.protocol === 'https', + }); + } + + const redirectUrl = openIdClient.buildAuthorizationUrl(config, parameters); + + return res.status(200).json({ + redirectUrl, + }); +}); + +authRoutes.get('/oidc/callback/:slug', async (req, res, next) => { + const settings = getSettings(); + const provider = settings.oidc.providers.find( + (p) => p.slug === req.params.slug + ); + + if (!settings.main.oidcLogin || !provider) { + return next({ + status: 403, + message: 'OpenID Connect sign-in is disabled', + }); + } + + try { + const config = await openIdClient.discovery( + new URL(provider.issuerUrl), + provider.clientId, + provider.clientSecret + ); + + const pkceCodeVerifier = req.cookies['oidc-code-verifier']; + const expectedState = req.cookies['oidc-state']; + + const redirectUrl = getOidcRedirectUrl(req); + redirectUrl.search = `?${req.url.split('?')[1] ?? ''}`; + + const tokens = await openIdClient.authorizationCodeGrant( + config, + redirectUrl, + { + pkceCodeVerifier, + expectedState, + } + ); + + const claims = tokens.claims(); + if (claims == null) { + logger.info('Failed OIDC login attempt', { + cause: + 'Missing ID token in response. Provider does not support OpenID Connect.', + ip: req.ip, + provider: provider.name, + }); + + return next({ + status: 400, + message: 'Invalid token response', + }); + } + + const requiredClaims = (provider.requiredClaims ?? '') + .split(' ') + .filter((s) => !!s); + + const userInfo = await openIdClient.fetchUserInfo( + config, + tokens.access_token, + claims.sub + ); + + const fullUserInfo = { ...claims, ...userInfo }; + + // Validate that user meets required claims + const hasRequiredClaims = requiredClaims.every((claim) => { + const value = userInfo[claim]; + return value === true; + }); + + if (!hasRequiredClaims) { + logger.info('Failed OIDC login attempt', { + cause: 'Failed to validate required claims', + ip: req.ip, + requiredClaims: provider.requiredClaims, + }); + return next({ + status: 403, + message: 'Insufficient permissions', + }); + } + + // Map identifier to linked account + const userRepository = getRepository(User); + const linkedAccountsRepository = getRepository(LinkedAccount); + + const linkedAccount = await linkedAccountsRepository.findOne({ + relations: { + user: true, + }, + where: { + provider: provider.slug, + sub: fullUserInfo.sub, + }, + }); + let user = linkedAccount?.user; + + // If there is already a user logged in, and no linked account, link the account. + if (req.user != null && linkedAccount == null) { + const linkedAccount = new LinkedAccount({ + user: req.user, + provider: provider.slug, + sub: fullUserInfo.sub, + username: fullUserInfo.preferred_username ?? req.user.displayName, + }); + + await linkedAccountsRepository.save(linkedAccount); + return res + .status(200) + .json({ status: 'ok', to: '/profile/settings/linked-accounts' }); + } + + // Create user if one doesn't already exist + if (!user && fullUserInfo.email != null && provider.newUserLogin) { + // Check if a user with this email already exists + const existingUser = await userRepository.findOne({ + where: { email: fullUserInfo.email }, + }); + + if (existingUser) { + // If a user with the email exists, throw a 409 Conflict error + return next({ + status: 409, + message: 'A user with this email address already exists.', + }); + } + + logger.info(`Creating user for ${fullUserInfo.email}`, { + ip: req.ip, + email: fullUserInfo.email, + }); + + const avatar = + fullUserInfo.picture ?? + gravatarUrl(fullUserInfo.email, { default: 'mm', size: 200 }); + user = new User({ + avatar: avatar, + username: fullUserInfo.preferred_username, + email: fullUserInfo.email, + permissions: settings.main.defaultPermissions, + plexToken: '', + userType: UserType.LOCAL, + }); + await userRepository.save(user); + + const linkedAccount = new LinkedAccount({ + user, + provider: provider.slug, + sub: fullUserInfo.sub, + username: fullUserInfo.preferred_username ?? fullUserInfo.email, + }); + await linkedAccountsRepository.save(linkedAccount); + + user.linkedAccounts = [linkedAccount]; + await userRepository.save(user); + } + + if (!user) { + logger.debug('Failed OIDC sign-up attempt', { + cause: provider.newUserLogin + ? 'User did not have an account, and was missing an associated email address.' + : 'User did not have an account, and new user login was disabled.', + }); + return next({ + status: 400, + message: provider.newUserLogin + ? 'Unable to create new user account (missing email address)' + : 'Unable to create new user account (new user login is disabled)', + }); + } + + // Set logged in session and return + if (req.session) { + req.session.userId = user.id; + } + + // Success! + return res.status(200).json({ status: 'ok', to: '/' }); + } catch (error) { + logger.error('Failed OIDC login attempt', { + cause: 'Unknown error', + ip: req.ip, + error, + }); + return next({ + status: 500, + message: 'An unknown error occurred', + }); + } +}); + authRoutes.post('/logout', async (req, res, next) => { try { const userId = req.session?.userId; @@ -739,7 +1001,7 @@ authRoutes.post('/reset-password', async (req, res, next) => { if (user) { await user.resetPassword(); - userRepository.save(user); + await userRepository.save(user); logger.info('Successfully sent password reset link', { label: 'API', ip: req.ip, @@ -804,7 +1066,7 @@ authRoutes.post('/reset-password/:guid', async (req, res, next) => { } user.recoveryLinkExpirationDate = null; await user.setPassword(req.body.password); - userRepository.save(user); + await userRepository.save(user); logger.info('Successfully reset password', { label: 'API', ip: req.ip, diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 683539680b..579a82762d 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -109,6 +109,45 @@ settingsRoutes.post('/main/regenerate', async (req, res, next) => { return res.status(200).json(filteredMainSettings(req.user, main)); }); +settingsRoutes.get('/oidc', async (req, res) => { + const settings = getSettings(); + + return res.status(200).json(settings.oidc); +}); + +settingsRoutes.put('/oidc/:slug', async (req, res) => { + const settings = getSettings(); + let provider = settings.oidc.providers.findIndex( + (p) => p.slug === req.params.slug + ); + + if (provider !== -1) { + Object.assign(settings.oidc.providers[provider], req.body); + } else { + settings.oidc.providers.push({ slug: req.params.slug, ...req.body }); + provider = settings.oidc.providers.length - 1; + } + + await settings.save(); + + return res.status(200).json(settings.oidc.providers[provider]); +}); + +settingsRoutes.delete('/oidc/:slug', async (req, res) => { + const settings = getSettings(); + const provider = settings.oidc.providers.findIndex( + (p) => p.slug === req.params.slug + ); + + if (provider === -1) + return res.status(404).json({ message: 'Provider not found' }); + + settings.oidc.providers.splice(provider, 1); + await settings.save(); + + return res.status(200).json(settings.oidc); +}); + settingsRoutes.get('/plex', (_req, res) => { const settings = getSettings(); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 50ea7c5a5f..56c1eedb7c 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -4,10 +4,13 @@ import { ApiErrorCode } from '@server/constants/error'; import { MediaServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; +import { LinkedAccount } from '@server/entity/LinkedAccount'; import { User } from '@server/entity/User'; import { UserSettings } from '@server/entity/UserSettings'; import type { UserSettingsGeneralResponse, + UserSettingsLinkedAccount, + UserSettingsLinkedAccountResponse, UserSettingsNotificationsResponse, } from '@server/interfaces/api/userSettingsInterfaces'; import { Permission } from '@server/lib/permissions'; @@ -18,7 +21,7 @@ import { ApiError } from '@server/types/error'; import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; import net from 'net'; -import { Not } from 'typeorm'; +import { In, Not, type FindOptionsWhere } from 'typeorm'; import { canMakePermissionsChange } from '.'; const isOwnProfile = (): Middleware => { @@ -543,6 +546,73 @@ userSettingsRoutes.delete<{ id: string }>( } ); +userSettingsRoutes.get<{ id: string }, UserSettingsLinkedAccountResponse>( + '/linked-accounts', + isOwnProfileOrAdmin(), + async (req, res) => { + const settings = getSettings(); + if (!settings.main.oidcLogin) { + // don't show any linked accounts if OIDC login is disabled + return res.status(200).json([]); + } + + const activeProviders = settings.oidc.providers.map((p) => p.slug); + const linkedAccountsRepository = getRepository(LinkedAccount); + + const linkedAccounts = await linkedAccountsRepository.find({ + relations: { + user: true, + }, + where: { + provider: In(activeProviders), + user: { + id: Number(req.params.id), + }, + }, + }); + + const linkedAccountInfo = linkedAccounts.map((acc) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const provider = settings.oidc.providers.find( + (p) => p.slug === acc.provider + )!; + + return { + id: acc.id, + username: acc.username, + provider: { + slug: provider.slug, + name: provider.name, + logo: provider.logo, + }, + } satisfies UserSettingsLinkedAccount; + }); + + return res.status(200).json(linkedAccountInfo); + } +); + +userSettingsRoutes.delete<{ id: string; acctId: string }>( + '/linked-accounts/:acctId', + isOwnProfileOrAdmin(), + async (req, res) => { + const linkedAccountsRepository = getRepository(LinkedAccount); + const condition: FindOptionsWhere = { + id: Number(req.params.acctId), + user: { + id: Number(req.params.id), + }, + }; + + if (await linkedAccountsRepository.exist({ where: condition })) { + await linkedAccountsRepository.delete(condition); + return res.status(204).send(); + } else { + return res.status(404).send(); + } + } +); + userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( '/notifications', isOwnProfileOrAdmin(), diff --git a/server/scripts/prepareTestDb.ts b/server/scripts/prepareTestDb.ts index 7caede41f8..ccde8bb74b 100644 --- a/server/scripts/prepareTestDb.ts +++ b/server/scripts/prepareTestDb.ts @@ -1,8 +1,5 @@ -import { UserType } from '@server/constants/user'; -import dataSource, { getRepository } from '@server/datasource'; -import { User } from '@server/entity/User'; +import { seedTestDb } from '@server/utils/seedTestDb'; import { copyFileSync } from 'fs'; -import gravatarUrl from 'gravatar-url'; import path from 'path'; const prepareDb = async () => { @@ -12,61 +9,10 @@ const prepareDb = async () => { path.join(__dirname, '../../config/settings.json') ); - // Connect to DB and seed test data - const dbConnection = await dataSource.initialize(); - - if (process.env.PRESERVE_DB !== 'true') { - await dbConnection.dropDatabase(); - } - - // Run migrations in production - if (process.env.WITH_MIGRATIONS === 'true') { - await dbConnection.runMigrations(); - } else { - await dbConnection.synchronize(); - } - - const userRepository = getRepository(User); - - const admin = await userRepository.findOne({ - select: { id: true, plexId: true }, - where: { id: 1 }, - }); - - // Create the admin user - const user = - (await userRepository.findOne({ - where: { email: 'admin@seerr.dev' }, - })) ?? new User(); - user.plexId = admin?.plexId ?? 1; - user.plexToken = '1234'; - user.plexUsername = 'admin'; - user.username = 'admin'; - user.email = 'admin@seerr.dev'; - user.userType = UserType.PLEX; - await user.setPassword('test1234'); - user.permissions = 2; - user.avatar = gravatarUrl('admin@seerr.dev', { default: 'mm', size: 200 }); - await userRepository.save(user); - - // Create the other user - const otherUser = - (await userRepository.findOne({ - where: { email: 'friend@seerr.dev' }, - })) ?? new User(); - otherUser.plexId = admin?.plexId ?? 1; - otherUser.plexToken = '1234'; - otherUser.plexUsername = 'friend'; - otherUser.username = 'friend'; - otherUser.email = 'friend@seerr.dev'; - otherUser.userType = UserType.PLEX; - await otherUser.setPassword('test1234'); - otherUser.permissions = 32; - otherUser.avatar = gravatarUrl('friend@seerr.dev', { - default: 'mm', - size: 200, + await seedTestDb({ + preserveDb: process.env.PRESERVE_DB === 'true', + withMigrations: process.env.WITH_MIGRATIONS === 'true', }); - await userRepository.save(otherUser); }; prepareDb(); diff --git a/server/test/db.ts b/server/test/db.ts new file mode 100644 index 0000000000..cf86dfd44d --- /dev/null +++ b/server/test/db.ts @@ -0,0 +1,11 @@ +import { resetTestDb, seedTestDb } from '@server/utils/seedTestDb'; +import { before, beforeEach } from 'node:test'; + +export function setupTestDb() { + before(async () => { + await seedTestDb(); + }); + beforeEach(async () => { + await resetTestDb(); + }); +} diff --git a/server/test/index.mts b/server/test/index.mts new file mode 100644 index 0000000000..94890b270e --- /dev/null +++ b/server/test/index.mts @@ -0,0 +1,120 @@ +// Runs unit tests using the `node:test` runner. + +import { Command, Option } from 'commander'; +import { createWriteStream } from 'node:fs'; +import { glob } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { run } from 'node:test'; +import * as reporters from 'node:test/reporters'; +import { fileURLToPath } from 'node:url'; + +const resolveImport = (specifier: string) => + fileURLToPath(import.meta.resolve(specifier)); +const BASE_DIR = join(import.meta.dirname, '../..'); + +const program = new Command(); +program + .name('test') + .argument('[file...]', 'Test file(s) to run (default: all)') + .option( + '-m, --test-name-pattern ', + 'Run tests matching the given pattern', + (v, acc: string[]) => [...acc, v], + [] as string[] + ) + .option( + '--test-reporter ', + 'Test reporter to use (repeatable)', + (v, acc: string[]) => [...acc, v], + [] as string[] + ) + .option( + '--test-reporter-destination ', + 'Test reporter destination: stdout, stderr, or a file path (repeatable)', + (v, acc: string[]) => [...acc, v], + [] as string[] + ) + .option( + '--coverage, --experimental-test-coverage', + 'Enable code coverage collection' + ) + // ignore additional options passed by vscode test runner + .addOption(new Option('--test').hideHelp()) + .parse(); + +const positionals: string[] = program.args; +const opts = program.opts<{ + testNamePattern: string[]; + testReporter: string[]; + testReporterDestination: string[]; + experimentalTestCoverage: boolean; +}>(); + +let files: string[]; + +if (positionals.length > 0) { + files = positionals.map((f) => resolve(f)); +} else { + files = []; + for await (const entry of glob(join(BASE_DIR, 'server/**/*.test.ts'))) { + files.push(resolve(entry)); + } + files.sort(); +} + +// configure ts +process.env.TS_NODE_PROJECT = resolveImport('../tsconfig.json'); +process.env.TS_NODE_FILES = 'true'; + +const stream = run({ + files, + execArgv: [ + '--experimental-test-module-mocks', + '-r', + 'ts-node/register', + '-r', + 'tsconfig-paths/register', + '-r', + resolveImport('./setup.ts'), + ], + coverage: opts.experimentalTestCoverage, + coverageExcludeGlobs: [ + join(BASE_DIR, 'server/test/**'), + join(BASE_DIR, 'server/migration/**'), + ], + testNamePatterns: opts.testNamePattern, +}); + +// In CI, write a JUnit report to a file for use by GitHub +if (process.env.CI) { + const reportStream = createWriteStream(join(BASE_DIR, 'report.xml')); + stream.compose(reporters.junit).pipe(reportStream); +} + +if (opts.testReporter.length > 0) { + for (let i = 0; i < opts.testReporter.length; i++) { + const reporterName = opts.testReporter[i]; + // check built-in reporters, otherwise import + const reporter = + reporterName in reporters + ? reporters[reporterName as keyof typeof reporters] + : await import(reporterName).then((m) => m.default); + + if (reporter == null) { + console.error('Invalid test reporter: ', reporterName); + process.exit(1); + } + + const destArg = opts.testReporterDestination[i]; + const dest = + destArg === 'stdout' || destArg == null + ? process.stdout + : destArg === 'stderr' + ? process.stderr + : createWriteStream(destArg); + + stream.compose(reporter).pipe(dest); + } +} else { + stream.compose(reporters.spec).pipe(process.stdout); +} diff --git a/server/test/setup.ts b/server/test/setup.ts new file mode 100644 index 0000000000..120a2a3316 --- /dev/null +++ b/server/test/setup.ts @@ -0,0 +1,10 @@ +import logger from '@server/logger'; +import { after, before } from 'node:test'; + +before(() => { + logger.silent = true; +}); + +after(() => { + logger.silent = false; +}); diff --git a/server/utils/seedTestDb.ts b/server/utils/seedTestDb.ts new file mode 100644 index 0000000000..266169d45c --- /dev/null +++ b/server/utils/seedTestDb.ts @@ -0,0 +1,96 @@ +import { UserType } from '@server/constants/user'; +import dataSource, { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import gravatarUrl from 'gravatar-url'; + +export interface SeedDbOptions { + /** If true, preserves existing data instead of dropping the database */ + preserveDb?: boolean; + /** If true, runs migrations instead of synchronizing schema */ + withMigrations?: boolean; +} + +// Precomputed bcrypt hash of 'test1234'. We precompute this to avoid +// having to hash the password every time we seed the database. +const TEST_USER_PASSWORD_HASH = + '$2b$12$Z5V2P5HZgmx4/AnWFMZN1.aD5AM1NucNi.mhNTSQ9oVtmdzu7Le/a'; + +/** + * Seeds test users into the database. + * Assumes the database schema is already set up. + */ +async function seedTestUsers(): Promise { + const userRepository = getRepository(User); + + const admin = await userRepository.findOne({ + select: { id: true, plexId: true }, + where: { id: 1 }, + }); + + // Create the admin user + const user = + (await userRepository.findOne({ + where: { email: 'admin@seerr.dev' }, + })) ?? new User(); + user.plexId = admin?.plexId ?? 1; + user.plexToken = '1234'; + user.plexUsername = 'admin'; + user.username = 'admin'; + user.email = 'admin@seerr.dev'; + user.userType = UserType.PLEX; + user.password = TEST_USER_PASSWORD_HASH; + user.permissions = 2; + user.avatar = gravatarUrl('admin@seerr.dev', { default: 'mm', size: 200 }); + await userRepository.save(user); + + // Create the other user + const otherUser = + (await userRepository.findOne({ + where: { email: 'friend@seerr.dev' }, + })) ?? new User(); + otherUser.plexId = admin?.plexId ?? 1; + otherUser.plexToken = '1234'; + otherUser.plexUsername = 'friend'; + otherUser.username = 'friend'; + otherUser.email = 'friend@seerr.dev'; + otherUser.userType = UserType.PLEX; + otherUser.password = TEST_USER_PASSWORD_HASH; + otherUser.permissions = 32; + otherUser.avatar = gravatarUrl('friend@seerr.dev', { + default: 'mm', + size: 200, + }); + await userRepository.save(otherUser); +} + +/** + * Initializes the database connection and seeds test users. + * Used by both Cypress tests and Vitest unit tests. + */ +export async function seedTestDb(options: SeedDbOptions = {}): Promise { + const dbConnection = dataSource.isInitialized + ? dataSource + : await dataSource.initialize(); + + if (!options.preserveDb) { + await dbConnection.dropDatabase(); + } + + if (options.withMigrations) { + await dbConnection.runMigrations(); + } else { + await dbConnection.synchronize(); + } + + await seedTestUsers(); +} + +/** + * Resets the database to a clean state with seeded test users. + * Used between tests to ensure isolation. + * Assumes DB has been initialized. + */ +export async function resetTestDb(): Promise { + await dataSource.synchronize(true); + await seedTestUsers(); +} diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index f6c9d3870c..a8de287620 100644 --- a/src/context/SettingsContext.tsx +++ b/src/context/SettingsContext.tsx @@ -8,7 +8,7 @@ export interface SettingsContextProps { children?: React.ReactNode; } -const defaultSettings = { +const defaultSettings: PublicSettingsResponse = { initialized: false, applicationTitle: 'Seerr', applicationUrl: '', @@ -31,6 +31,7 @@ const defaultSettings = { emailEnabled: false, newPlexLogin: true, youtubeUrl: '', + openIdProviders: [], }; export const SettingsContext = React.createContext({ diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 9fe3661ed3..5f36728345 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -249,6 +249,7 @@ CoreApp.getInitialProps = async (initialProps) => { emailEnabled: false, newPlexLogin: true, youtubeUrl: '', + openIdProviders: [], }; if (ctx.res) {