From 74414a36fcfae36744402c36809e8f59846de614 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Tue, 17 Feb 2026 07:56:11 -0500 Subject: [PATCH 01/10] build: add infrastructure for server-side testing --- .github/workflows/ci.yml | 45 ++ .gitignore | 1 + .vscode/extensions.json | 3 +- .vscode/settings.json | 9 +- package.json | 6 + pnpm-lock.yaml | 1586 +++++++++++++++++++++++++++++++++++++- server/test/index.mts | 120 +++ server/test/setup.ts | 10 + 8 files changed, 1767 insertions(+), 13 deletions(-) create mode 100644 server/test/index.mts create mode 100644 server/test/setup.ts 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..faebd76c8c 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", @@ -135,6 +136,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 +147,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", @@ -161,12 +164,15 @@ "eslint-plugin-react-hooks": "4.6.0", "husky": "8.0.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..cc2cf8698b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -316,6 +316,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 +349,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 +378,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) @@ -394,6 +400,9 @@ importers: 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 +418,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 +442,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 +678,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 +1197,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 +1311,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 +1417,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 +1681,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 +1791,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 +1996,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 +2059,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 +2092,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 +2845,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 +3070,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 +3102,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==} @@ -2995,6 +3171,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 +3271,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 +3459,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 +3843,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 +3893,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 +3979,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 +4094,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 +4145,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 +4208,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 +4275,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 +4317,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 +4409,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 +4686,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 +4789,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 +5098,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 +5110,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 +5177,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 @@ -4951,6 +5302,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 +5376,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'} @@ -5221,6 +5580,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 +5697,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 +5837,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 +5992,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 +6028,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 @@ -5938,6 +6457,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 +6557,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 +6956,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 +7063,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'} @@ -6892,6 +7429,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 +7613,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 +7719,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'} @@ -7487,6 +8035,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 +8344,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 +8452,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 +8571,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 +8622,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 +8663,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 +8800,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 +8875,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 +8914,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 +9044,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 +9118,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 +9175,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 +9319,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 +9440,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 +9738,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 +10404,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,12 +10614,28 @@ snapshots: tsscmp: 1.0.6 uid-safe: 2.1.5 - '@emnapi/runtime@1.2.0': + '@emnapi/core@1.8.1': dependencies: + '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emotion/babel-plugin@11.13.5': + '@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 '@babel/runtime': 7.28.6 @@ -10107,6 +10771,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 +10899,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 +10908,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 +11050,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 +11118,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 +11145,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 +11270,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 +11366,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 +11406,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 +11442,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 +12821,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 +12831,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 +13036,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 +13084,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': @@ -12260,6 +13166,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 +13260,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 +13511,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 +13853,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 +13881,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 +13947,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 +14057,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 +14205,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 +14267,8 @@ snapshots: ci-info@4.3.1: {} + cjs-module-lexer@2.2.0: {} + classnames@2.5.1: {} clean-stack@2.2.0: {} @@ -13290,6 +14329,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 +14387,8 @@ snapshots: command-exists@1.2.9: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -13385,6 +14430,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 +14531,8 @@ snapshots: cookie@1.0.2: {} + cookiejar@2.1.4: {} + copy-to-clipboard@3.3.3: dependencies: toggle-selection: 1.0.6 @@ -13801,8 +14850,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 +15023,8 @@ snapshots: - walrus - whiskers + emittery@0.13.1: {} + emoji-regex@10.3.0: {} emoji-regex@8.0.0: {} @@ -14214,10 +15269,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 +15504,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 +15634,8 @@ snapshots: fast-printf@1.6.10: {} + fast-safe-stringify@2.1.1: {} + fast-xml-parser@4.5.3: dependencies: strnum: 1.1.2 @@ -14711,6 +15779,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 +15874,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 @@ -14955,7 +16031,6 @@ snapshots: wordwrap: 1.0.0 optionalDependencies: uglify-js: 3.19.3 - optional: true hard-rejection@2.1.0: {} @@ -15025,6 +16100,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 +16253,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 +16392,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 +16512,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 +16571,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 +16683,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 +16734,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 +16871,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 +16889,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 +16916,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 @@ -15815,6 +17242,8 @@ snapshots: lodash.map@4.6.0: {} + lodash.memoize@4.1.2: {} + lodash.merge@4.6.2: {} lodash.mergewith@4.6.2: {} @@ -15918,6 +17347,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 +17960,8 @@ snapshots: napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -16638,6 +18073,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: @@ -17001,6 +18445,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 +18563,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 +18706,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@7.0.1: {} + q@1.5.1: {} qs@6.13.0: @@ -17740,6 +19196,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 +19576,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 +19682,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 +19818,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 +19883,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 +19956,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 +20064,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 +20164,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + tunnel@0.0.6: {} + tweetnacl@0.14.5: {} type-check@0.4.0: @@ -18662,6 +20186,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 +20288,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 +20368,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 +20434,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 +20620,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 +20648,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/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; +}); From 119c4f065f04c7594d320cd34d8dfe3695e8a76d Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Tue, 17 Feb 2026 08:01:32 -0500 Subject: [PATCH 02/10] test: support in-memory database for server tests --- server/datasource.ts | 15 +++++- server/scripts/prepareTestDb.ts | 62 ++------------------- server/test/db.ts | 11 ++++ server/utils/seedTestDb.ts | 96 +++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 59 deletions(-) create mode 100644 server/test/db.ts create mode 100644 server/utils/seedTestDb.ts 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/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/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(); +} From 5f0d22b85ed9e1673f11cf57d624d35456cc3fb1 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Tue, 17 Feb 2026 08:36:56 -0500 Subject: [PATCH 03/10] fix(auth): await calls to userRepository.save --- server/routes/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index b74befe5e5..83a9499a31 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -739,7 +739,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 +804,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, From 3b7f6261e12ea424d6a4c001f3d0cd66dd515e1a Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Tue, 17 Feb 2026 08:37:30 -0500 Subject: [PATCH 04/10] fix(user): filter password hash from responses --- server/entity/User.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/entity/User.ts b/server/entity/User.ts index b15b6683a7..d5ad4b861d 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -39,7 +39,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; From 8ca600f96284b6c2ed2a791c855f1f9fb17adf71 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Tue, 17 Feb 2026 08:41:13 -0500 Subject: [PATCH 05/10] fix(user): convert recoveryLinkExpirationDate from 'date' to 'datetime' TypeORM represents the 'date' column type (date only) as a string, not a JS date object. This was causing the reset password expiration check to never activate, since it compares the date (which is a string) with new Date(), which is always truthy. --- server/entity/User.ts | 2 +- ...37333450-RecoveryLinkExpirationDateTime.ts | 17 ++++++++++++ ...37037917-RecoveryLinkExpirationDateTime.ts | 27 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 server/migration/postgres/1771337333450-RecoveryLinkExpirationDateTime.ts create mode 100644 server/migration/sqlite/1771337037917-RecoveryLinkExpirationDateTime.ts diff --git a/server/entity/User.ts b/server/entity/User.ts index d5ad4b861d..0c046662d1 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -70,7 +70,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 }) 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/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"`); + } +} From 7ecb018ba977e13ef85706baaf8b33eeeb89408e Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Tue, 17 Feb 2026 08:52:20 -0500 Subject: [PATCH 06/10] test(auth): add test coverage for local auth endpoints --- server/routes/auth.test.ts | 391 +++++++++++++++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 server/routes/auth.test.ts diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts new file mode 100644 index 0000000000..bc55fa9bad --- /dev/null +++ b/server/routes/auth.test.ts @@ -0,0 +1,391 @@ +import assert from 'node:assert/strict'; +import { before, beforeEach, describe, it, mock } from 'node:test'; + +import { getRepository } from '@server/datasource'; +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 type { Express } from 'express'; +import express from 'express'; +import session from 'express-session'; +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( + 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(); +}); + +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); + }); +}); From 8aa4873a3232e649b783b80a7d925eac0525a2e9 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 9 Feb 2026 00:09:23 -0500 Subject: [PATCH 07/10] feat: implement OpenID Connect provider settings --- seerr-api.yml | 93 +++++++++++++++++++++ server/interfaces/api/settingsInterfaces.ts | 2 + server/lib/settings/index.ts | 41 +++++++++ server/routes/settings/index.ts | 39 +++++++++ src/context/SettingsContext.tsx | 3 +- src/pages/_app.tsx | 1 + 6 files changed, 178 insertions(+), 1 deletion(-) diff --git a/seerr-api.yml b/seerr-api.yml index aaaf30bf4a..3d7870cf83 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -254,6 +254,41 @@ 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 + OidcSettings: + type: object + properties: + providers: + type: array + items: + $ref: '#/components/schemas/OidcProvider' NetworkSettings: type: object properties: @@ -2215,6 +2250,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 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/lib/settings/index.ts b/server/lib/settings/index.ts index 0aa9da1596..5c8babe8a6 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[]; @@ -392,6 +414,7 @@ class Settings { hideBlocklisted: false, localLogin: true, mediaServerLogin: true, + oidcLogin: false, newPlexLogin: true, discoverRegion: '', streamingRegion: '', @@ -423,6 +446,9 @@ class Settings { serverId: '', apiKey: '', }, + oidc: { + providers: [], + }, tautulli: {}, metadataSettings: { tv: MetadataProviderType.TMDB, @@ -625,6 +651,14 @@ class Settings { 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; } @@ -697,6 +731,13 @@ class Settings { 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, + })) + : [], }; } 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/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) { From 958d78e65217573becad61087fe4fb49351e90b4 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 9 Feb 2026 00:17:02 -0500 Subject: [PATCH 08/10] feat: support linking OpenID Connect accounts to users --- seerr-api.yml | 68 ++++++++++++++++++ server/entity/LinkedAccount.ts | 27 +++++++ server/entity/User.ts | 4 ++ .../interfaces/api/userSettingsInterfaces.ts | 13 +++- .../1742858617989-AddLinkedAccount.ts | 21 ++++++ .../sqlite/1742858484395-AddLinkedAccounts.ts | 15 ++++ server/routes/user/usersettings.ts | 72 ++++++++++++++++++- 7 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 server/entity/LinkedAccount.ts create mode 100644 server/migration/postgres/1742858617989-AddLinkedAccount.ts create mode 100644 server/migration/sqlite/1742858484395-AddLinkedAccounts.ts diff --git a/seerr-api.yml b/seerr-api.yml index 3d7870cf83..2bcaf226fc 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -282,6 +282,20 @@ components: - 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: @@ -289,6 +303,15 @@ components: 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: @@ -5028,6 +5051,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 @@ -5126,6 +5172,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/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 0c046662d1..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'; @@ -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/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/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/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/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(), From bc2fa666b3e472b406cf330086d96c1d35eeb102 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 9 Feb 2026 00:18:37 -0500 Subject: [PATCH 09/10] feat: support login with OpenID Connect --- package.json | 1 + pnpm-lock.yaml | 21 ++++ seerr-api.yml | 63 ++++++++++ server/routes/auth.ts | 264 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 348 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index faebd76c8c..80846836de 100644 --- a/package.json +++ b/package.json @@ -74,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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc2cf8698b..6e1d9c491b 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 @@ -6195,6 +6198,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==} @@ -7133,6 +7139,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'} @@ -7214,6 +7223,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'} @@ -16950,6 +16962,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: {} @@ -18158,6 +18172,8 @@ snapshots: nullthrows@1.1.1: {} + oauth4webapi@3.8.4: {} + ob1@0.80.12: dependencies: flow-enums-runtime: 0.0.6 @@ -18247,6 +18263,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: diff --git a/seerr-api.yml b/seerr-api.yml index 2bcaf226fc..d5d4a73641 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -4128,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 diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 83a9499a31..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; From e4d4d7064837dcd9395b6828edcb99ecb7d1156e Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Wed, 18 Feb 2026 17:35:48 -0500 Subject: [PATCH 10/10] test(auth): add tests for OpenID Connect endpoints --- package.json | 2 + pnpm-lock.yaml | 33 +++ server/lib/settings/index.ts | 485 ++++++++++++++++++----------------- server/routes/auth.test.ts | 329 +++++++++++++++++++++++- 4 files changed, 616 insertions(+), 233 deletions(-) diff --git a/package.json b/package.json index 80846836de..36634911fc 100644 --- a/package.json +++ b/package.json @@ -163,7 +163,9 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e1d9c491b..a6745df219 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -397,9 +397,15 @@ 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) @@ -3138,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==} @@ -5208,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'} @@ -5436,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'} @@ -8013,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'} @@ -13143,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 @@ -15670,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 @@ -15945,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 @@ -19175,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 diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 5c8babe8a6..3a4d43ddc4 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -392,239 +392,16 @@ 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) { - this.data = { - clientId: randomUUID(), - vapidPrivate: '', - vapidPublic: '', - main: { - apiKey: '', - applicationTitle: 'Seerr', - applicationUrl: '', - cacheImages: false, - defaultPermissions: Permission.REQUEST, - defaultQuotas: { - movie: {}, - tv: {}, - }, - hideAvailable: false, - hideBlocklisted: false, - localLogin: true, - mediaServerLogin: true, - oidcLogin: false, - newPlexLogin: true, - discoverRegion: '', - streamingRegion: '', - originalLanguage: '', - blocklistedTags: '', - blocklistedTagsLimit: 50, - mediaServerType: MediaServerType.NOT_CONFIGURED, - partialRequestsEnabled: true, - enableSpecialEpisodes: false, - locale: 'en', - youtubeUrl: '', - }, - plex: { - name: '', - ip: '', - port: 32400, - useSsl: false, - libraries: [], - }, - jellyfin: { - name: '', - ip: '', - port: 8096, - useSsl: false, - urlBase: '', - externalHostname: '', - jellyfinForgotPasswordUrl: '', - libraries: [], - serverId: '', - apiKey: '', - }, - oidc: { - providers: [], - }, - tautulli: {}, - metadataSettings: { - tv: MetadataProviderType.TMDB, - anime: MetadataProviderType.TMDB, - }, - radarr: [], - sonarr: [], - public: { - initialized: false, - }, - notifications: { - agents: { - email: { - enabled: false, - embedPoster: true, - options: { - userEmailRequired: false, - emailFrom: '', - smtpHost: '', - smtpPort: 587, - secure: false, - ignoreTls: false, - requireTls: false, - allowSelfSigned: false, - senderName: 'Seerr', - }, - }, - discord: { - enabled: false, - embedPoster: true, - types: 0, - options: { - webhookUrl: '', - webhookRoleId: '', - enableMentions: true, - }, - }, - slack: { - enabled: false, - embedPoster: true, - types: 0, - options: { - webhookUrl: '', - }, - }, - telegram: { - enabled: false, - embedPoster: true, - types: 0, - options: { - botAPI: '', - chatId: '', - messageThreadId: '', - sendSilently: false, - }, - }, - pushbullet: { - enabled: false, - embedPoster: false, - types: 0, - options: { - accessToken: '', - }, - }, - pushover: { - enabled: false, - embedPoster: true, - types: 0, - options: { - accessToken: '', - userToken: '', - sound: '', - }, - }, - webhook: { - enabled: false, - embedPoster: true, - types: 0, - options: { - webhookUrl: '', - jsonPayload: - 'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i', - }, - }, - webpush: { - enabled: false, - embedPoster: true, - options: {}, - }, - gotify: { - enabled: false, - embedPoster: false, - types: 0, - options: { - url: '', - token: '', - priority: 0, - }, - }, - ntfy: { - enabled: false, - embedPoster: true, - types: 0, - options: { - url: '', - topic: '', - }, - }, - }, - }, - jobs: { - 'plex-recently-added-scan': { - schedule: '0 */5 * * * *', - }, - 'plex-full-scan': { - schedule: '0 0 3 * * *', - }, - 'plex-watchlist-sync': { - schedule: '0 */3 * * * *', - }, - 'plex-refresh-token': { - schedule: '0 0 5 * * *', - }, - 'radarr-scan': { - schedule: '0 0 4 * * *', - }, - 'sonarr-scan': { - schedule: '0 30 4 * * *', - }, - 'availability-sync': { - schedule: '0 0 5 * * *', - }, - 'download-sync': { - schedule: '0 * * * * *', - }, - 'download-sync-reset': { - schedule: '0 0 1 * * *', - }, - 'jellyfin-recently-added-scan': { - schedule: '0 */5 * * * *', - }, - 'jellyfin-full-scan': { - schedule: '0 0 3 * * *', - }, - 'image-cache-cleanup': { - schedule: '0 0 5 * * *', - }, - 'process-blocklisted-tags': { - schedule: '0 30 1 */7 * *', - }, - }, - network: { - csrfProtection: false, - forceIpv4First: false, - trustProxy: false, - proxy: { - enabled: false, - hostname: '', - port: 8080, - useSsl: false, - user: '', - password: '', - bypassFilter: '', - bypassLocalAddresses: true, - }, - dnsCache: { - enabled: false, - forceMinTtl: 0, - forceMaxTtl: -1, - }, - }, - migrations: [], - }; - if (initialSettings) { - this.data = merge(this.data, initialSettings); - } + constructor(initialSettings?: DeepPartial) { + this.reset(); + if (initialSettings) this.load(initialSettings); } get main(): MainSettings { @@ -809,11 +586,23 @@ class Settings { * values */ public async load( - overrideSettings?: AllSettings, + overrideSettings?: DeepPartial, + raw?: false + ): Promise; + public async load( + overrideSettings: AllSettings | undefined, + raw: true + ): Promise; + public async load( + overrideSettings?: DeepPartial, raw = false ): Promise { if (overrideSettings) { - this.data = overrideSettings; + if (raw) { + this.data = overrideSettings as AllSettings; + } else { + this.data = merge(this.data, overrideSettings); + } return this; } @@ -864,6 +653,238 @@ class Settings { 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: '', + vapidPublic: '', + main: { + apiKey: '', + applicationTitle: 'Seerr', + applicationUrl: '', + cacheImages: false, + defaultPermissions: Permission.REQUEST, + defaultQuotas: { + movie: {}, + tv: {}, + }, + hideAvailable: false, + hideBlocklisted: false, + localLogin: true, + mediaServerLogin: true, + oidcLogin: false, + newPlexLogin: true, + discoverRegion: '', + streamingRegion: '', + originalLanguage: '', + blocklistedTags: '', + blocklistedTagsLimit: 50, + mediaServerType: MediaServerType.NOT_CONFIGURED, + partialRequestsEnabled: true, + enableSpecialEpisodes: false, + locale: 'en', + youtubeUrl: '', + }, + plex: { + name: '', + ip: '', + port: 32400, + useSsl: false, + libraries: [], + }, + jellyfin: { + name: '', + ip: '', + port: 8096, + useSsl: false, + urlBase: '', + externalHostname: '', + jellyfinForgotPasswordUrl: '', + libraries: [], + serverId: '', + apiKey: '', + }, + oidc: { + providers: [], + }, + tautulli: {}, + metadataSettings: { + tv: MetadataProviderType.TMDB, + anime: MetadataProviderType.TMDB, + }, + radarr: [], + sonarr: [], + public: { + initialized: false, + }, + notifications: { + agents: { + email: { + enabled: false, + embedPoster: true, + options: { + userEmailRequired: false, + emailFrom: '', + smtpHost: '', + smtpPort: 587, + secure: false, + ignoreTls: false, + requireTls: false, + allowSelfSigned: false, + senderName: 'Seerr', + }, + }, + discord: { + enabled: false, + embedPoster: true, + types: 0, + options: { + webhookUrl: '', + webhookRoleId: '', + enableMentions: true, + }, + }, + slack: { + enabled: false, + embedPoster: true, + types: 0, + options: { + webhookUrl: '', + }, + }, + telegram: { + enabled: false, + embedPoster: true, + types: 0, + options: { + botAPI: '', + chatId: '', + messageThreadId: '', + sendSilently: false, + }, + }, + pushbullet: { + enabled: false, + embedPoster: false, + types: 0, + options: { + accessToken: '', + }, + }, + pushover: { + enabled: false, + embedPoster: true, + types: 0, + options: { + accessToken: '', + userToken: '', + sound: '', + }, + }, + webhook: { + enabled: false, + embedPoster: true, + types: 0, + options: { + webhookUrl: '', + jsonPayload: + 'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i', + }, + }, + webpush: { + enabled: false, + embedPoster: true, + options: {}, + }, + gotify: { + enabled: false, + embedPoster: false, + types: 0, + options: { + url: '', + token: '', + priority: 0, + }, + }, + ntfy: { + enabled: false, + embedPoster: true, + types: 0, + options: { + url: '', + topic: '', + }, + }, + }, + }, + jobs: { + 'plex-recently-added-scan': { + schedule: '0 */5 * * * *', + }, + 'plex-full-scan': { + schedule: '0 0 3 * * *', + }, + 'plex-watchlist-sync': { + schedule: '0 */3 * * * *', + }, + 'plex-refresh-token': { + schedule: '0 0 5 * * *', + }, + 'radarr-scan': { + schedule: '0 0 4 * * *', + }, + 'sonarr-scan': { + schedule: '0 30 4 * * *', + }, + 'availability-sync': { + schedule: '0 0 5 * * *', + }, + 'download-sync': { + schedule: '0 * * * * *', + }, + 'download-sync-reset': { + schedule: '0 0 1 * * *', + }, + 'jellyfin-recently-added-scan': { + schedule: '0 */5 * * * *', + }, + 'jellyfin-full-scan': { + schedule: '0 0 3 * * *', + }, + 'image-cache-cleanup': { + schedule: '0 0 5 * * *', + }, + 'process-blocklisted-tags': { + schedule: '0 30 1 */7 * *', + }, + }, + network: { + csrfProtection: false, + forceIpv4First: false, + trustProxy: false, + proxy: { + enabled: false, + hostname: '', + port: 8080, + useSsl: false, + user: '', + password: '', + bypassFilter: '', + bypassLocalAddresses: true, + }, + dnsCache: { + enabled: false, + forceMinTtl: 0, + forceMaxTtl: -1, + }, + }, + migrations: [], + }; + } } let settings: Settings | undefined; diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts index bc55fa9bad..30cce71740 100644 --- a/server/routes/auth.test.ts +++ b/server/routes/auth.test.ts @@ -1,14 +1,25 @@ import assert from 'node:assert/strict'; -import { before, beforeEach, describe, it, mock } from 'node:test'; +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'; @@ -24,6 +35,7 @@ let app: Express; function createApp() { const app = express(); app.use(express.json()); + app.use(cookieParser()); app.use( session({ secret: 'test-secret', @@ -55,6 +67,10 @@ before(async () => { app = createApp(); }); +afterEach(() => { + getSettings().reset(); +}); + setupTestDb(); /** Create a supertest agent that is logged in as the given user. */ @@ -389,3 +405,314 @@ describe('POST /auth/reset-password/:guid', () => { 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); + }); + }); +});