diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index be7202b..c35764a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -28,3 +28,35 @@ jobs: - name: Check for critical vulnerabilities run: npm audit --audit-level=critical --json | jq -e '.metadata.vulnerabilities.critical == 0' || (echo "Critical vulnerabilities found!" && exit 1) continue-on-error: false + + test: + name: Tests & coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run tests with coverage + run: npm run test:coverage + env: + # Required by tokenStore (writable temp dir in CI) + DATA_DIR: /tmp/sofarr-ci-data + # Disable rate limiters so integration tests don't hit 429s + SKIP_RATE_LIMIT: "1" + NODE_ENV: test + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 23fe371..47ec192 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +coverage/ .env dist/ build/ diff --git a/README.md b/README.md index 9a709a4..da76491 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,17 @@ Logs are written to both console and `server.log` file. - Check qBittorrent Web UI is enabled - Verify the URL includes the full path (e.g., `https://qb.example.com`) +## Testing + +```bash +npm test # run all tests once +npm run test:watch # watch mode +npm run test:coverage # with V8 coverage report (outputs to coverage/) +npm run test:ui # interactive Vitest UI +``` + +115 tests across 8 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, and qBittorrent utilities. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets. + ## Development ```bash diff --git a/package-lock.json b/package-lock.json index 367eb8b..65a10a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,48 @@ "helmet": "^7.0.0" }, "devDependencies": { + "@vitest/coverage-v8": "^4.1.6", "concurrently": "^7.6.0", - "nodemon": "^3.1.14" + "nock": "^14.0.15", + "nodemon": "^3.1.14", + "supertest": "^7.2.2", + "vitest": "^4.1.6" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/@babel/runtime": { @@ -31,6 +71,638 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", + "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.6", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.6", + "vitest": "4.1.6" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.6", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -125,6 +797,35 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -254,6 +955,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -356,6 +1067,16 @@ "node": ">= 0.8" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concurrently": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz", @@ -405,6 +1126,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -433,6 +1161,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -487,6 +1222,27 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -553,6 +1309,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -596,6 +1359,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -605,6 +1378,16 @@ "node": ">= 0.6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -666,6 +1449,13 @@ "express": ">= 4.11" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -733,6 +1523,24 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -905,6 +1713,13 @@ "node": ">=16.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1041,6 +1856,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -1051,6 +1873,333 @@ "node": ">=0.12.0" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -1058,6 +2207,44 @@ "dev": true, "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1149,6 +2336,25 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1158,6 +2364,21 @@ "node": ">= 0.6" } }, + "node_modules/nock": { + "version": "14.0.15", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.15.tgz", + "integrity": "sha512-S0a47C9pLvcYx/Ugf0H30BVBEcUgMMBDk9VJIDlJ8XGrfH2QDUD4Tgdp45qDIiHttokBG+IbsOtsvIjGR/j3bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mswjs/interceptors": "^0.41.0", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">=18.20.0 <20 || >=20.12.1" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -1257,6 +2478,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1269,6 +2501,23 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1284,6 +2533,20 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", @@ -1297,6 +2560,45 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1388,6 +2690,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -1573,6 +2909,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -1586,12 +2929,29 @@ "node": ">=10" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/spawn-command": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", "dev": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1601,6 +2961,20 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1629,6 +3003,90 @@ "node": ">=8" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "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" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -1645,6 +3103,81 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1741,6 +3274,217 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -1759,6 +3503,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 509abec..764747d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,10 @@ "dev": "nodemon server/index.js", "start": "node server/index.js", "install:all": "npm install", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:ui": "vitest --ui", "audit": "npm audit --audit-level=high", "audit:fix": "npm audit fix", "audit:critical": "npm audit --audit-level=critical" @@ -20,8 +24,12 @@ "helmet": "^7.0.0" }, "devDependencies": { + "@vitest/coverage-v8": "^4.1.6", "concurrently": "^7.6.0", - "nodemon": "^3.1.14" + "nock": "^14.0.15", + "nodemon": "^3.1.14", + "supertest": "^7.2.2", + "vitest": "^4.1.6" }, "keywords": [ "sabnzbd", diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..ad98959 --- /dev/null +++ b/server/app.js @@ -0,0 +1,113 @@ +/** + * Express application factory — imported by both server/index.js (production) + * and the test suite. Keeping app creation separate from app.listen() means + * tests can import a fresh instance without starting a real server or + * triggering the side-effects in index.js (log files, process.exit, poller). + */ + +const express = require('express'); +const cookieParser = require('cookie-parser'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); +const crypto = require('crypto'); + +const sabnzbdRoutes = require('./routes/sabnzbd'); +const sonarrRoutes = require('./routes/sonarr'); +const radarrRoutes = require('./routes/radarr'); +const embyRoutes = require('./routes/emby'); +const dashboardRoutes = require('./routes/dashboard'); +const authRoutes = require('./routes/auth'); +const verifyCsrf = require('./middleware/verifyCsrf'); + +function createApp({ skipRateLimits = false } = {}) { + const app = express(); + + if (process.env.TRUST_PROXY) { + const trustValue = /^\d+$/.test(process.env.TRUST_PROXY) + ? parseInt(process.env.TRUST_PROXY, 10) + : process.env.TRUST_PROXY; + app.set('trust proxy', trustValue); + } + + // Per-request CSP nonce + app.use((req, res, next) => { + res.locals.cspNonce = crypto.randomBytes(16).toString('base64'); + next(); + }); + + app.use((req, res, next) => { + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`], + styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`], + imgSrc: ["'self'", 'data:', 'blob:'], + fontSrc: ["'self'", 'data:'], + connectSrc: ["'self'"], + objectSrc: ["'none'"], + baseUri: ["'self'"], + frameAncestors: ["'none'"], + formAction: ["'self'"], + upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null + } + }, + hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, + referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, + crossOriginEmbedderPolicy: false + })(req, res, next); + }); + + app.use((req, res, next) => { + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()'); + next(); + }); + + const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, please try again later' } + }); + + app.use(cookieParser(process.env.COOKIE_SECRET || undefined)); + app.use(express.json({ limit: '64kb' })); + + // Health / readiness (no auth, no rate-limit) + app.get('/health', (req, res) => { + res.json({ status: 'ok', uptime: process.uptime() }); + }); + + app.get('/ready', (req, res) => { + const ready = !!(process.env.EMBY_URL); + if (ready) { + res.json({ status: 'ready' }); + } else { + res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' }); + } + }); + + // API routes + app.use('/api', apiLimiter); + app.use('/api/auth', authRoutes); + + // CSRF protection for all state-changing API requests below + app.use('/api', verifyCsrf); + app.use('/api/sabnzbd', sabnzbdRoutes); + app.use('/api/sonarr', sonarrRoutes); + app.use('/api/radarr', radarrRoutes); + app.use('/api/emby', embyRoutes); + app.use('/api/dashboard', dashboardRoutes); + + // Global error handler + // eslint-disable-next-line no-unused-vars + app.use((err, req, res, next) => { + console.error('[Server] Unhandled error:', err.message); + res.status(500).json({ error: 'Internal server error' }); + }); + + return app; +} + +module.exports = { createApp }; diff --git a/server/routes/auth.js b/server/routes/auth.js index 0f34dc7..e87b46f 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -7,13 +7,16 @@ const router = express.Router(); // Persistent SQLite-backed token store — survives restarts const { storeToken, getToken, clearToken } = require('../utils/tokenStore'); -const EMBY_URL = process.env.EMBY_URL; +// Read EMBY_URL at request time (not module load time) so the value +// can be overridden by environment variables set after the module loads. +const getEmbyUrl = () => process.env.EMBY_URL; - -// Strict login limiter: 10 attempts per 15 min, then locked for the window +// Strict login limiter: 10 attempts per 15 min, then locked for the window. +// Set SKIP_RATE_LIMIT=1 in the test environment to prevent the limiter from +// interfering with integration tests (all requests come from 127.0.0.1). const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, - max: 10, + max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 10, standardHeaders: true, legacyHeaders: false, skipSuccessfulRequests: true, // only count failures toward the limit @@ -39,7 +42,7 @@ router.post('/login', loginLimiter, async (req, res) => { // Using a deterministic DeviceId causes Emby to reuse the existing session // for this device rather than creating a new one on each login. const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.trim().toLowerCase()).digest('hex').slice(0, 16); - const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, { + const authResponse = await axios.post(`${getEmbyUrl()}/Users/authenticatebyname`, { Username: username.trim(), Pw: password }, { @@ -51,7 +54,7 @@ router.post('/login', loginLimiter, async (req, res) => { const authData = authResponse.data; // Get user info using the access token - const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, { + const userResponse = await axios.get(`${getEmbyUrl()}/Users/${authData.User.Id || authData.User.id}`, { headers: { 'X-MediaBrowser-Token': authData.AccessToken } @@ -153,7 +156,7 @@ router.post('/logout', async (req, res) => { const stored = getToken(user.id); if (stored) { try { - await axios.post(`${EMBY_URL}/Sessions/Logout`, {}, { + await axios.post(`${getEmbyUrl()}/Sessions/Logout`, {}, { headers: { 'X-MediaBrowser-Token': stored.accessToken } }); console.log(`[Auth] Revoked Emby token for user: ${user.name}`); diff --git a/server/utils/sanitizeError.js b/server/utils/sanitizeError.js index b0a479e..cd52112 100644 --- a/server/utils/sanitizeError.js +++ b/server/utils/sanitizeError.js @@ -1,7 +1,8 @@ // Query-param secrets (SABnzbd apikey, generic token/password params) const QUERY_SECRET_PATTERN = /([?&](?:apikey|token|password|api_key|key|secret)=)[^&\s#]*/gi; // HTTP auth header values (X-Api-Key, X-MediaBrowser-Token, Authorization, X-Emby-Authorization) -const HEADER_PATTERN = /(?:x-api-key|x-mediabrowser-token|x-emby-authorization|authorization)\s*:\s*\S+/gi; +// Redact everything after the colon to end-of-line (MediaBrowser headers span the full line) +const HEADER_PATTERN = /(?:x-api-key|x-mediabrowser-token|x-emby-authorization|authorization)\s*:[^\n]*/gi; // Bearer tokens const BEARER_PATTERN = /bearer\s+[A-Za-z0-9\-._~+/]+=*/gi; // Basic auth credentials in URLs (http://user:pass@host) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..8cf6db9 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,67 @@ +# Testing + +## Stack + +| Layer | Tool | +|---|---| +| Test runner | [Vitest](https://vitest.dev/) v4 | +| HTTP integration | [supertest](https://github.com/ladjs/supertest) | +| HTTP interception | [nock](https://github.com/nock/nock) (intercepts at Node http layer — works with CJS `require('axios')`) | +| Coverage | V8 (built-in, no Babel needed) | + +## Running tests + +```bash +# Run all tests once +npm test + +# Watch mode (re-runs on file change) +npm run test:watch + +# With coverage report +npm run test:coverage + +# Interactive UI +npm run test:ui +``` + +Coverage output lands in `coverage/` (gitignored). Open `coverage/index.html` for the HTML report. + +## Structure + +``` +tests/ +├── setup.js # Global setup: isolated DATA_DIR, SKIP_RATE_LIMIT, console suppression +├── unit/ +│ ├── sanitizeError.test.js # Secret redaction patterns (API keys, tokens, passwords) +│ ├── config.test.js # JSON array + legacy single-instance config parsing +│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies +│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare +│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload +│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry +└── integration/ + ├── health.test.js # GET /health and /ready endpoints + └── auth.test.js # Full login/logout/me/csrf flows via supertest + nock +``` + +## Key design decisions + +- **`server/app.js`** — Express factory extracted from `server/index.js`. Tests import `createApp()` without triggering the log-file setup, `process.exit()` calls, or background poller in the entry point. +- **nock over `vi.mock('axios')`** — Vitest's `vi.mock` only intercepts ESM `import` statements. Since `auth.js` uses CJS `require('axios')`, nock (which patches Node's `http`/`https` modules) is the correct tool for intercepting outbound requests. +- **`SKIP_RATE_LIMIT=1`** — All supertest requests originate from `127.0.0.1`, which would quickly exhaust per-IP rate-limit windows. Setting this env var raises the limits to `Number.MAX_SAFE_INTEGER` in both the API limiter and the login limiter. +- **Isolated `DATA_DIR`** — Each test worker gets a unique temp directory so `tokenStore.js` file I/O never conflicts with a running dev server. +- **`createApp({ skipRateLimits: true })`** — The app factory accepts an option to disable the general API rate limiter in addition to the env var for the login-specific limiter. + +## Coverage targets + +The tested files meet these per-file minimums (enforced in CI): + +| File | Lines | Branches | +|---|---|---| +| `server/app.js` | 85% | 65% | +| `server/routes/auth.js` | 85% | 70% | +| `server/middleware/requireAuth.js` | 75% | 80% | +| `server/utils/sanitizeError.js` | 60% | — | +| `server/utils/config.js` | 50% | 55% | + +`dashboard.js` and `poller.js` are large files requiring complex external-service mocks (Sonarr, Radarr, qBittorrent, Emby) and are tracked as future test coverage work. diff --git a/tests/integration/auth.test.js b/tests/integration/auth.test.js new file mode 100644 index 0000000..ca98d33 --- /dev/null +++ b/tests/integration/auth.test.js @@ -0,0 +1,299 @@ +/** + * Integration tests for authentication routes. + * + * Uses supertest against the createApp() factory (no real server). + * HTTP calls to Emby are intercepted at the Node http/https layer using nock, + * which works correctly with CJS require('axios') unlike vi.mock which only + * intercepts ESM imports. + * + * Covers: + * - Input validation on /login (empty fields, overlong values) + * - Successful login flow (cookies set, CSRF token returned) + * - Failed login (wrong credentials → 401, no cookie set) + * - /me endpoint (authenticated vs unauthenticated) + * - /csrf token issuance + * - /logout (cookies cleared) + */ + +import request from 'supertest'; +import nock from 'nock'; +import { createApp } from '../../server/app.js'; + +const EMBY_BASE = 'https://emby.test'; + +// Emby response fixtures +const EMBY_AUTH_BODY = { + AccessToken: 'test-emby-token-abc123', + User: { Id: 'user-id-001', Name: 'TestUser' } +}; + +const EMBY_USER_BODY = { + Id: 'user-id-001', + Name: 'TestUser', + Policy: { IsAdministrator: false } +}; + +const EMBY_ADMIN_BODY = { + Id: 'admin-id-001', + Name: 'AdminUser', + Policy: { IsAdministrator: true } +}; + +// Helper: intercept a successful Emby login + user-info sequence +function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) { + nock(EMBY_BASE) + .post('/Users/authenticatebyname') + .reply(200, EMBY_AUTH_BODY); + nock(EMBY_BASE) + .get(/\/Users\//) + .reply(200, userBody); +} + +afterEach(() => { + nock.cleanAll(); // remove any pending interceptors between tests +}); + +describe('POST /api/auth/login', () => { + // Each sub-describe gets a fresh app to avoid rate-limit state leaking + // between the 'input validation' calls (which all fail and count toward + // the 10-failure window) and the 'successful login' calls. + let app; + + beforeEach(() => { + process.env.EMBY_URL = 'https://emby.test'; + delete process.env.COOKIE_SECRET; + // skipRateLimits avoids 429s from the login limiter when all + // requests come from 127.0.0.1 in the test environment + app = createApp({ skipRateLimits: true }); + vi.clearAllMocks(); + }); + + afterEach(() => { + delete process.env.EMBY_URL; + delete process.env.COOKIE_SECRET; + }); + + describe('input validation', () => { + it('rejects empty username', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ username: '', password: 'pass' }); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('rejects missing password', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'alice', password: '' }); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('rejects username over 128 chars', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'a'.repeat(129), password: 'pass' }); + expect(res.status).toBe(400); + }); + + it('rejects password over 256 chars', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'alice', password: 'p'.repeat(257) }); + expect(res.status).toBe(400); + }); + + it('rejects non-string username', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ username: 123, password: 'pass' }); + expect(res.status).toBe(400); + }); + }); + + describe('successful login', () => { + it('returns success:true with user info', async () => { + interceptSuccessfulLogin(); + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'TestUser', password: 'correct' }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.user.name).toBe('TestUser'); + expect(res.body.user.isAdmin).toBe(false); + }); + + it('sets emby_user cookie', async () => { + interceptSuccessfulLogin(); + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'TestUser', password: 'correct' }); + const cookies = res.headers['set-cookie'] || []; + expect(cookies.some(c => c.startsWith('emby_user='))).toBe(true); + }); + + it('sets csrf_token cookie', async () => { + interceptSuccessfulLogin(); + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'TestUser', password: 'correct' }); + const cookies = res.headers['set-cookie'] || []; + expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true); + }); + + it('returns csrfToken in response body', async () => { + interceptSuccessfulLogin(); + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'TestUser', password: 'correct' }); + expect(typeof res.body.csrfToken).toBe('string'); + expect(res.body.csrfToken.length).toBeGreaterThan(0); + }); + + it('session cookie has no maxAge when rememberMe is false', async () => { + interceptSuccessfulLogin(); + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'TestUser', password: 'correct', rememberMe: false }); + const cookies = res.headers['set-cookie'] || []; + const sessionCookie = cookies.find(c => c.startsWith('emby_user=')); + // Session cookie must not persist across browser close + expect(sessionCookie).toBeDefined(); + expect(sessionCookie).not.toContain('Max-Age'); + }); + + it('sets 30-day maxAge when rememberMe is true', async () => { + interceptSuccessfulLogin(); + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'TestUser', password: 'correct', rememberMe: true }); + const cookies = res.headers['set-cookie'] || []; + const sessionCookie = cookies.find(c => c.startsWith('emby_user=')); + expect(sessionCookie).toBeDefined(); + expect(sessionCookie).toContain('Max-Age'); + }); + + it('marks isAdmin correctly for admin user', async () => { + interceptSuccessfulLogin(EMBY_ADMIN_BODY); + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'AdminUser', password: 'correct' }); + expect(res.status).toBe(200); + expect(res.body.user.isAdmin).toBe(true); + }); + + it('does not include AccessToken in response body', async () => { + interceptSuccessfulLogin(); + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'TestUser', password: 'correct' }); + // The Emby access token must never be sent to the client + expect(JSON.stringify(res.body)).not.toContain('test-emby-token-abc123'); + }); + }); + + describe('failed login', () => { + it('returns 401 when Emby rejects credentials', async () => { + nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, { error: 'Unauthorized' }); + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'baduser', password: 'wrongpass' }); + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + // Must not expose internal error details + expect(res.body.error).toBe('Invalid username or password'); + }); + + it('does not set emby_user cookie on failure', async () => { + nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, {}); + const res = await request(app) + .post('/api/auth/login') + .send({ username: 'baduser', password: 'wrongpass' }); + const cookies = res.headers['set-cookie'] || []; + expect(cookies.some(c => c.startsWith('emby_user='))).toBe(false); + }); + }); +}); + +describe('GET /api/auth/me', () => { + let app; + + beforeEach(() => { + delete process.env.COOKIE_SECRET; + app = createApp({ skipRateLimits: true }); + }); + + it('returns authenticated:false when no cookie', async () => { + const res = await request(app).get('/api/auth/me'); + expect(res.status).toBe(200); + expect(res.body.authenticated).toBe(false); + }); + + it('returns authenticated:true with valid cookie', async () => { + const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: false }); + const res = await request(app) + .get('/api/auth/me') + .set('Cookie', `emby_user=${encodeURIComponent(payload)}`); + expect(res.body.authenticated).toBe(true); + expect(res.body.user.name).toBe('Alice'); + }); +}); + +describe('GET /api/auth/csrf', () => { + it('issues a csrf_token cookie and returns csrfToken in body', async () => { + const app = createApp({ skipRateLimits: true }); + const res = await request(app).get('/api/auth/csrf'); + expect(res.status).toBe(200); + expect(typeof res.body.csrfToken).toBe('string'); + expect(res.body.csrfToken.length).toBe(64); // 32 bytes hex + const cookies = res.headers['set-cookie'] || []; + expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true); + }); +}); + +describe('POST /api/auth/logout', () => { + let app; + + beforeEach(() => { + process.env.EMBY_URL = 'https://emby.test'; + delete process.env.COOKIE_SECRET; + app = createApp({ skipRateLimits: true }); + vi.clearAllMocks(); + }); + + afterEach(() => { + delete process.env.EMBY_URL; + }); + + // NOTE: /api/auth/* is mounted BEFORE the verifyCsrf middleware in app.js, + // so logout does not require a CSRF token by design. The session cookie's + // sameSite:strict attribute provides equivalent CSRF protection for logout. + it('succeeds without a CSRF token (sameSite:strict provides protection)', async () => { + nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {}); + const res = await request(app) + .post('/api/auth/logout'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('clears cookies and returns success when CSRF token is provided', async () => { + const csrfRes = await request(app).get('/api/auth/csrf'); + const csrfToken = csrfRes.body.csrfToken; + const csrfCookie = csrfRes.headers['set-cookie'].find(c => c.startsWith('csrf_token=')); + + nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {}); + + const res = await request(app) + .post('/api/auth/logout') + .set('Cookie', csrfCookie) + .set('X-CSRF-Token', csrfToken); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + // Cookies should be cleared (Set-Cookie header with empty value / Max-Age=0) + const cookies = res.headers['set-cookie'] || []; + expect(cookies.some(c => c.includes('emby_user=;') || c.includes('Max-Age=0'))).toBe(true); + }); +}); diff --git a/tests/integration/health.test.js b/tests/integration/health.test.js new file mode 100644 index 0000000..fbe806b --- /dev/null +++ b/tests/integration/health.test.js @@ -0,0 +1,55 @@ +/** + * Integration tests for health and readiness endpoints. + * + * /health and /ready are used by Docker HEALTHCHECK and must: + * - Require no authentication + * - Not be rate-limited + * - Return the correct status codes + */ + +import request from 'supertest'; +import { createApp } from '../../server/app.js'; + +describe('GET /health', () => { + let app; + + beforeEach(() => { + app = createApp(); + }); + + it('returns 200 with status ok', async () => { + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + }); + + it('includes uptime as a number', async () => { + const res = await request(app).get('/health'); + expect(typeof res.body.uptime).toBe('number'); + expect(res.body.uptime).toBeGreaterThanOrEqual(0); + }); +}); + +describe('GET /ready', () => { + let app; + + afterEach(() => { + delete process.env.EMBY_URL; + }); + + it('returns 200 when EMBY_URL is configured', async () => { + process.env.EMBY_URL = 'https://emby.local'; + app = createApp(); + const res = await request(app).get('/ready'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ready'); + }); + + it('returns 503 when EMBY_URL is not configured', async () => { + delete process.env.EMBY_URL; + app = createApp(); + const res = await request(app).get('/ready'); + expect(res.status).toBe(503); + expect(res.body.status).toBe('not ready'); + }); +}); diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..83ec3d4 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,27 @@ +import { vi, beforeEach, afterEach } from 'vitest'; +import os from 'os'; +import path from 'path'; +import fs from 'fs'; + +// Give each test worker a unique temp DATA_DIR so tokenStore file I/O is +// fully isolated and doesn't conflict with a running dev server's data/. +const tmpDir = path.join(os.tmpdir(), `sofarr-test-${process.pid}`); +if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true }); +process.env.DATA_DIR = tmpDir; + +// Disable rate limiters in tests — all supertest requests share 127.0.0.1 +// and would quickly exhaust per-IP windows otherwise. +process.env.SKIP_RATE_LIMIT = '1'; + +// Suppress console noise during tests (errors still surface via thrown exceptions) +beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + // clearAllMocks resets call history and queued return values without + // restoring mock implementations — use restoreAllMocks only for spies. + vi.clearAllMocks(); +}); diff --git a/tests/unit/config.test.js b/tests/unit/config.test.js new file mode 100644 index 0000000..544e5de --- /dev/null +++ b/tests/unit/config.test.js @@ -0,0 +1,108 @@ +/** + * Tests for server/utils/config.js + * + * Verifies that instance config is parsed correctly from both the modern JSON + * array format and the legacy single-instance env var format. This is critical + * because misconfigured instances silently return no data rather than crashing. + */ + +import { parseInstances, getSonarrInstances, getRadarrInstances } from '../../server/utils/config.js'; + +describe('parseInstances', () => { + describe('JSON array format', () => { + it('parses a valid single-instance JSON array', () => { + const json = JSON.stringify([{ name: 'main', url: 'https://sonarr.local', apiKey: 'abc123' }]); + const result = parseInstances(json, null, null); + expect(result).toHaveLength(1); + expect(result[0].url).toBe('https://sonarr.local'); + expect(result[0].apiKey).toBe('abc123'); + }); + + it('parses multiple instances', () => { + const json = JSON.stringify([ + { name: 'main', url: 'https://s1.local', apiKey: 'key1' }, + { name: 'backup', url: 'https://s2.local', apiKey: 'key2' } + ]); + const result = parseInstances(json, null, null); + expect(result).toHaveLength(2); + expect(result[1].name).toBe('backup'); + }); + + it('adds id from name when present', () => { + const json = JSON.stringify([{ name: 'i3omb', url: 'https://s.local', apiKey: 'k' }]); + const result = parseInstances(json, null, null); + expect(result[0].id).toBe('i3omb'); + }); + + it('generates fallback id when name is absent', () => { + const json = JSON.stringify([{ url: 'https://s.local', apiKey: 'k' }]); + const result = parseInstances(json, null, null); + expect(result[0].id).toBe('instance-1'); + }); + + it('handles multi-line JSON by stripping whitespace', () => { + const json = `[ + { + "name": "main", + "url": "https://sonarr.local", + "apiKey": "abc" + } + ]`; + const result = parseInstances(json, null, null); + expect(result).toHaveLength(1); + }); + + it('returns empty array for empty JSON array', () => { + expect(parseInstances('[]', null, null)).toEqual([]); + }); + + it('falls back to legacy format when JSON is malformed', () => { + const result = parseInstances('not-json', 'https://legacy.local', 'legacyKey'); + expect(result).toHaveLength(1); + expect(result[0].url).toBe('https://legacy.local'); + }); + }); + + describe('legacy single-instance format', () => { + it('returns single instance from legacy URL + key', () => { + const result = parseInstances(null, 'https://sonarr.local', 'legacyapikey'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('default'); + expect(result[0].name).toBe('Default'); + expect(result[0].url).toBe('https://sonarr.local'); + expect(result[0].apiKey).toBe('legacyapikey'); + }); + + it('returns empty array for qBittorrent with no apiKey and no JSON (legacy requires key)', () => { + // parseInstances requires legacyKey to be truthy for the legacy path; + // qBittorrent uses JSON array format, not the legacy URL+key path. + const result = parseInstances(null, 'https://qbt.local', null, 'admin', 'pass123'); + expect(result).toEqual([]); + }); + + it('returns empty array when both JSON and legacy URL are missing', () => { + expect(parseInstances(null, null, null)).toEqual([]); + }); + + it('returns empty array when URL is set but key is missing', () => { + expect(parseInstances(null, 'https://sonarr.local', null)).toEqual([]); + }); + }); + + describe('env-based getters', () => { + it('getSonarrInstances reads SONARR_INSTANCES from env', () => { + process.env.SONARR_INSTANCES = JSON.stringify([{ name: 'test', url: 'https://s.local', apiKey: 'k' }]); + const result = getSonarrInstances(); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('test'); + delete process.env.SONARR_INSTANCES; + }); + + it('getRadarrInstances returns empty array when unconfigured', () => { + delete process.env.RADARR_INSTANCES; + delete process.env.RADARR_URL; + const result = getRadarrInstances(); + expect(result).toEqual([]); + }); + }); +}); diff --git a/tests/unit/qbittorrent.test.js b/tests/unit/qbittorrent.test.js new file mode 100644 index 0000000..72cc467 --- /dev/null +++ b/tests/unit/qbittorrent.test.js @@ -0,0 +1,111 @@ +/** + * Tests for server/utils/qbittorrent.js pure utility functions. + * + * mapTorrentToDownload, formatBytes, formatSpeed, and formatEta are all + * pure functions with no I/O — ideal unit test targets. These power the + * dashboard card rendering so correctness matters for UX. + */ + +import { mapTorrentToDownload, formatBytes, formatSpeed, formatEta } from '../../server/utils/qbittorrent.js'; + +// Minimal torrent fixture that satisfies mapTorrentToDownload's expectations +function makeTorrent(overrides = {}) { + return { + name: 'My.Show.S01E01.1080p.mkv', + state: 'downloading', + size: 1073741824, // 1 GB + completed: 536870912, // 512 MB + progress: 0.5, + dlspeed: 1048576, // 1 MB/s + eta: 512, // seconds + num_seeds: 10, + num_leechs: 3, + availability: 1.0, + hash: 'aabbccdd', + category: 'sonarr', + tags: '', + content_path: '/downloads/My.Show.S01E01.1080p.mkv', + save_path: '/downloads/', + instanceName: 'i3omb', + ...overrides + }; +} + +describe('formatBytes', () => { + it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 B')); + it('formats bytes', () => expect(formatBytes(512)).toBe('512 B')); + it('formats kilobytes', () => expect(formatBytes(1024)).toBe('1 KB')); + it('formats megabytes', () => expect(formatBytes(1048576)).toBe('1 MB')); + it('formats gigabytes', () => expect(formatBytes(1073741824)).toBe('1 GB')); + it('formats fractional GB', () => expect(formatBytes(1610612736)).toBe('1.5 GB')); +}); + +describe('formatSpeed', () => { + it('appends /s to byte count', () => expect(formatSpeed(1048576)).toBe('1 MB/s')); + it('handles zero speed', () => expect(formatSpeed(0)).toBe('0 B/s')); +}); + +describe('formatEta', () => { + it('returns ∞ for qBittorrent unknown sentinel (8640000)', () => { + expect(formatEta(8640000)).toBe('∞'); + }); + it('returns ∞ for negative eta', () => expect(formatEta(-1)).toBe('∞')); + it('formats minutes only', () => expect(formatEta(90)).toBe('1m')); + it('formats hours and minutes', () => expect(formatEta(3661)).toBe('1h 1m')); + it('formats days, hours and minutes', () => expect(formatEta(90061)).toBe('1d 1h 1m')); + it('returns 0m for zero seconds', () => expect(formatEta(0)).toBe('0m')); +}); + +describe('mapTorrentToDownload', () => { + it('maps a downloading torrent correctly', () => { + const result = mapTorrentToDownload(makeTorrent()); + expect(result.status).toBe('Downloading'); + expect(result.progress).toBe('50.0'); + expect(result.size).toBe('1 GB'); + expect(result.speed).toBe('1 MB/s'); + expect(result.eta).toBe('8m'); + expect(result.seeds).toBe(10); + expect(result.peers).toBe(3); + expect(result.qbittorrent).toBe(true); + expect(result.instanceName).toBe('i3omb'); + }); + + it('maps state: stalledDL → Downloading', () => { + expect(mapTorrentToDownload(makeTorrent({ state: 'stalledDL' })).status).toBe('Downloading'); + }); + + it('maps state: uploading → Seeding', () => { + expect(mapTorrentToDownload(makeTorrent({ state: 'uploading' })).status).toBe('Seeding'); + }); + + it('maps state: pausedDL → Paused', () => { + expect(mapTorrentToDownload(makeTorrent({ state: 'pausedDL' })).status).toBe('Paused'); + }); + + it('maps state: stoppedUP → Stopped', () => { + expect(mapTorrentToDownload(makeTorrent({ state: 'stoppedUP' })).status).toBe('Stopped'); + }); + + it('maps state: error → Error', () => { + expect(mapTorrentToDownload(makeTorrent({ state: 'error' })).status).toBe('Error'); + }); + + it('passes through unknown state verbatim', () => { + expect(mapTorrentToDownload(makeTorrent({ state: 'weirdState' })).status).toBe('weirdState'); + }); + + it('computes 100% progress for completed torrent', () => { + const result = mapTorrentToDownload(makeTorrent({ progress: 1.0 })); + expect(result.progress).toBe('100.0'); + }); + + it('uses content_path as savePath when present', () => { + const result = mapTorrentToDownload(makeTorrent({ content_path: '/dl/file.mkv' })); + expect(result.savePath).toBe('/dl/file.mkv'); + }); + + it('falls back to save_path when content_path is absent', () => { + const result = mapTorrentToDownload(makeTorrent({ content_path: null, save_path: '/dl/' })); + expect(result.savePath).toBe('/dl/'); + }); +}); diff --git a/tests/unit/requireAuth.test.js b/tests/unit/requireAuth.test.js new file mode 100644 index 0000000..87cefb4 --- /dev/null +++ b/tests/unit/requireAuth.test.js @@ -0,0 +1,140 @@ +/** + * Tests for server/middleware/requireAuth.js + * + * requireAuth guards all authenticated API routes. Tests exercise the full + * range of valid/invalid cookie states to ensure there's no bypass path. + */ + +import requireAuth from '../../server/middleware/requireAuth.js'; + +// Build mock req/res/next objects +function makeReq({ signedCookie, plainCookie, cookieSecret } = {}) { + // Set COOKIE_SECRET so signed path is taken when provided + if (cookieSecret !== undefined) { + process.env.COOKIE_SECRET = cookieSecret; + } else { + delete process.env.COOKIE_SECRET; + } + + return { + signedCookies: { emby_user: signedCookie }, + cookies: { emby_user: plainCookie } + }; +} + +function makeRes() { + const res = { + statusCode: null, + body: null, + status(code) { this.statusCode = code; return this; }, + json(body) { this.body = body; return this; } + }; + return res; +} + +afterEach(() => { + delete process.env.COOKIE_SECRET; +}); + +describe('requireAuth middleware', () => { + describe('valid sessions', () => { + it('calls next() with a valid signed cookie', () => { + const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: true }); + const req = makeReq({ signedCookie: payload, cookieSecret: 'secret' }); + const res = makeRes(); + const next = vi.fn(); + + requireAuth(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(req.user).toMatchObject({ id: 'u1', name: 'Alice', isAdmin: true }); + }); + + it('calls next() with a valid unsigned cookie (no COOKIE_SECRET)', () => { + const payload = JSON.stringify({ id: 'u2', name: 'Bob', isAdmin: false }); + const req = makeReq({ plainCookie: payload }); + const res = makeRes(); + const next = vi.fn(); + + requireAuth(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(req.user.id).toBe('u2'); + }); + + it('coerces non-boolean isAdmin to boolean', () => { + const payload = JSON.stringify({ id: 'u3', name: 'Charlie', isAdmin: 1 }); + const req = makeReq({ plainCookie: payload }); + const res = makeRes(); + const next = vi.fn(); + + requireAuth(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.user.isAdmin).toBe(true); + }); + }); + + describe('missing or invalid cookies', () => { + it('returns 401 when no cookie is present', () => { + const req = makeReq({}); + const res = makeRes(); + const next = vi.fn(); + + requireAuth(req, res, next); + + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 401 when signed cookie value is false (tampered)', () => { + // cookie-parser sets signed cookie to false when signature is invalid + const req = makeReq({ signedCookie: false, cookieSecret: 'secret' }); + const res = makeRes(); + const next = vi.fn(); + + requireAuth(req, res, next); + + expect(res.statusCode).toBe(401); + }); + + it('returns 401 for malformed JSON in cookie', () => { + const req = makeReq({ plainCookie: 'not-json' }); + const res = makeRes(); + const next = vi.fn(); + + requireAuth(req, res, next); + + expect(res.statusCode).toBe(401); + expect(res.body.error).toBe('Invalid session'); + }); + + it('returns 401 when id is missing', () => { + const payload = JSON.stringify({ name: 'Alice', isAdmin: false }); + const req = makeReq({ plainCookie: payload }); + requireAuth(req, makeRes(), vi.fn()); + // no next called — handled in the assertion below + const res = makeRes(); + const next = vi.fn(); + requireAuth(req, res, next); + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 401 when name is missing', () => { + const payload = JSON.stringify({ id: 'u1', isAdmin: false }); + const req = makeReq({ plainCookie: payload }); + const res = makeRes(); + requireAuth(req, res, vi.fn()); + expect(res.statusCode).toBe(401); + }); + + it('returns 401 when id is empty string', () => { + const payload = JSON.stringify({ id: '', name: 'Alice', isAdmin: false }); + const req = makeReq({ plainCookie: payload }); + const res = makeRes(); + requireAuth(req, res, vi.fn()); + expect(res.statusCode).toBe(401); + }); + }); +}); diff --git a/tests/unit/sanitizeError.test.js b/tests/unit/sanitizeError.test.js new file mode 100644 index 0000000..a6f3266 --- /dev/null +++ b/tests/unit/sanitizeError.test.js @@ -0,0 +1,121 @@ +/** + * Tests for server/utils/sanitizeError.js + * + * Critical security tests: verify that API keys, tokens, passwords and other + * secrets are NEVER leaked in error messages returned to clients or written + * to logs. Every pattern here represents a real credential type used in the + * sofarr stack (SABnzbd apikey, Emby tokens, qBittorrent basic-auth URLs). + */ + +import sanitizeError from '../../server/utils/sanitizeError.js'; + +describe('sanitizeError', () => { + describe('query-param secrets', () => { + it('redacts ?apikey= values', () => { + const err = new Error('Request failed: https://sabnzbd.local/api?apikey=abc123secret&output=json'); + expect(sanitizeError(err)).toContain('[REDACTED]'); + expect(sanitizeError(err)).not.toContain('abc123secret'); + }); + + it('redacts &apikey= mid-URL', () => { + const err = new Error('GET https://host/path?mode=queue&apikey=SUPERSECRET&output=json'); + expect(sanitizeError(err)).not.toContain('SUPERSECRET'); + expect(sanitizeError(err)).toContain('[REDACTED]'); + }); + + it('redacts ?token= values', () => { + const err = new Error('https://api.example.com/data?token=tok_private99'); + expect(sanitizeError(err)).not.toContain('tok_private99'); + }); + + it('redacts ?password= values', () => { + const err = new Error('Auth failed: https://service.local?password=hunter2'); + expect(sanitizeError(err)).not.toContain('hunter2'); + }); + + it('redacts ?api_key= values', () => { + const err = new Error('https://sonarr.local/api/v3/series?api_key=e583d270f89846478e42'); + expect(sanitizeError(err)).not.toContain('e583d270f89846478e42'); + }); + + it('preserves non-secret query params', () => { + const result = sanitizeError(new Error('GET /api?mode=queue&output=json')); + expect(result).toContain('mode=queue'); + expect(result).toContain('output=json'); + }); + }); + + describe('HTTP auth headers', () => { + it('redacts X-Api-Key header values', () => { + const err = new Error('Request: x-api-key: e583d270f89846478e42dd3cf90bfb00'); + expect(sanitizeError(err)).not.toContain('e583d270f89846478e42dd3cf90bfb00'); + expect(sanitizeError(err)).toContain('[REDACTED]'); + }); + + it('redacts X-MediaBrowser-Token header values', () => { + const err = new Error('x-mediabrowser-token: b862f3a43f4c417285043a11aa28b1f7'); + expect(sanitizeError(err)).not.toContain('b862f3a43f4c417285043a11aa28b1f7'); + }); + + it('redacts Authorization header values', () => { + const err = new Error('authorization: MediaBrowser Token="abc123", DeviceId="xyz"'); + expect(sanitizeError(err)).not.toContain('abc123'); + }); + }); + + describe('bearer tokens', () => { + it('redacts Bearer token values', () => { + const err = new Error('Error: bearer eyJhbGciOiJIUzI1NiJ9.payload.sig'); + expect(sanitizeError(err)).not.toContain('eyJhbGciOiJIUzI1NiJ9'); + expect(sanitizeError(err)).toContain('bearer [REDACTED]'); + }); + + it('is case-insensitive for BEARER', () => { + const err = new Error('BEARER TOKEN_VALUE_HERE'); + expect(sanitizeError(err)).not.toContain('TOKEN_VALUE_HERE'); + }); + }); + + describe('basic-auth URLs', () => { + it('redacts user:pass@ in URLs', () => { + const err = new Error('GET http://admin:b053288369XX!@qbittorrent.local/api'); + expect(sanitizeError(err)).not.toContain('b053288369XX!'); + expect(sanitizeError(err)).not.toContain('admin:'); + expect(sanitizeError(err)).toContain('//[REDACTED]@'); + }); + + it('handles https:// basic auth', () => { + const err = new Error('https://user:s3cr3t@service.local/path'); + expect(sanitizeError(err)).not.toContain('s3cr3t'); + }); + }); + + describe('edge cases', () => { + it('handles non-Error input (plain string)', () => { + const result = sanitizeError('plain string error'); + expect(typeof result).toBe('string'); + }); + + it('handles null gracefully', () => { + expect(() => sanitizeError(null)).not.toThrow(); + }); + + it('handles undefined gracefully', () => { + expect(() => sanitizeError(undefined)).not.toThrow(); + }); + + it('preserves non-sensitive error messages unchanged', () => { + const err = new Error('Connection refused: ECONNREFUSED 127.0.0.1:8080'); + const result = sanitizeError(err); + expect(result).toContain('ECONNREFUSED'); + expect(result).toContain('127.0.0.1:8080'); + }); + + it('does not leak stack traces (returns message only)', () => { + const err = new Error('something went wrong'); + const result = sanitizeError(err); + expect(result).not.toContain('at '); + expect(result).not.toContain('.js:'); + }); + }); +}); diff --git a/tests/unit/tokenStore.test.js b/tests/unit/tokenStore.test.js new file mode 100644 index 0000000..53e0c3c --- /dev/null +++ b/tests/unit/tokenStore.test.js @@ -0,0 +1,84 @@ +/** + * Tests for server/utils/tokenStore.js + * + * The token store persists Emby access tokens to disk (JSON file) so users + * survive server restarts without re-logging in. Tests verify the store/get/ + * clear lifecycle, TTL expiry, and atomic write behaviour. + * + * Each test imports a FRESH module instance (vi.resetModules) so the + * module-level singleton state (loaded from disk) doesn't bleed between tests. + */ + +import { vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Each test gets its own isolated temp dir +let tmpDir; +let tokenStore; + +async function freshStore(dir) { + vi.resetModules(); + process.env.DATA_DIR = dir; + const mod = await import('../../server/utils/tokenStore.js'); + return mod; +} + +beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sofarr-ts-')); + tokenStore = await freshStore(tmpDir); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('tokenStore', () => { + it('stores and retrieves a token', () => { + tokenStore.storeToken('user1', 'access-token-abc'); + const result = tokenStore.getToken('user1'); + expect(result).not.toBeNull(); + expect(result.accessToken).toBe('access-token-abc'); + }); + + it('returns null for an unknown user', () => { + expect(tokenStore.getToken('nobody')).toBeNull(); + }); + + it('clears a stored token', () => { + tokenStore.storeToken('user1', 'token-xyz'); + tokenStore.clearToken('user1'); + expect(tokenStore.getToken('user1')).toBeNull(); + }); + + it('clearToken is a no-op for unknown user', () => { + expect(() => tokenStore.clearToken('ghost')).not.toThrow(); + }); + + it('overwrites existing token on re-store', () => { + tokenStore.storeToken('user1', 'old-token'); + tokenStore.storeToken('user1', 'new-token'); + expect(tokenStore.getToken('user1').accessToken).toBe('new-token'); + }); + + it('persists to disk (tokens.json exists after store)', () => { + tokenStore.storeToken('u1', 'tok'); + const storePath = path.join(tmpDir, 'tokens.json'); + expect(fs.existsSync(storePath)).toBe(true); + const data = JSON.parse(fs.readFileSync(storePath, 'utf8')); + expect(data.u1.accessToken).toBe('tok'); + }); + + it('expires tokens older than 31 days on read', () => { + // Write an already-expired entry directly to disk + const expired = Date.now() - (32 * 24 * 60 * 60 * 1000); + const storePath = path.join(tmpDir, 'tokens.json'); + fs.writeFileSync(storePath, JSON.stringify({ u1: { accessToken: 'old', createdAt: expired } })); + // Re-import to load from disk + vi.resetModules(); + return import('../../server/utils/tokenStore.js').then(mod => { + expect(mod.getToken('u1')).toBeNull(); + }); + }); +}); diff --git a/tests/unit/verifyCsrf.test.js b/tests/unit/verifyCsrf.test.js new file mode 100644 index 0000000..3f0e631 --- /dev/null +++ b/tests/unit/verifyCsrf.test.js @@ -0,0 +1,84 @@ +/** + * Tests for server/middleware/verifyCsrf.js + * + * CSRF protection via the double-submit cookie pattern. These tests verify + * that the timing-safe comparison works correctly and that safe HTTP methods + * are correctly exempted. + */ + +import verifyCsrf from '../../server/middleware/verifyCsrf.js'; + +function makeReq(method, cookieToken, headerToken) { + return { + method, + cookies: { csrf_token: cookieToken }, + headers: { 'x-csrf-token': headerToken } + }; +} + +function makeRes() { + const res = { + statusCode: null, + body: null, + status(code) { this.statusCode = code; return this; }, + json(body) { this.body = body; return this; } + }; + return res; +} + +describe('verifyCsrf middleware', () => { + describe('safe methods are exempted', () => { + for (const method of ['GET', 'HEAD', 'OPTIONS']) { + it(`allows ${method} with no CSRF token`, () => { + const next = vi.fn(); + verifyCsrf(makeReq(method, undefined, undefined), makeRes(), next); + expect(next).toHaveBeenCalledOnce(); + }); + } + }); + + describe('mutating methods require valid token', () => { + const TOKEN = 'a'.repeat(64); // 64 hex chars = 32 bytes + + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + it(`allows ${method} with matching tokens`, () => { + const next = vi.fn(); + const res = makeRes(); + verifyCsrf(makeReq(method, TOKEN, TOKEN), res, next); + expect(next).toHaveBeenCalledOnce(); + expect(res.statusCode).toBeNull(); + }); + + it(`blocks ${method} with mismatched tokens`, () => { + const next = vi.fn(); + const res = makeRes(); + verifyCsrf(makeReq(method, TOKEN, TOKEN.replace('a', 'b')), res, next); + expect(res.statusCode).toBe(403); + expect(next).not.toHaveBeenCalled(); + }); + + it(`blocks ${method} with missing cookie token`, () => { + const next = vi.fn(); + const res = makeRes(); + verifyCsrf(makeReq(method, undefined, TOKEN), res, next); + expect(res.statusCode).toBe(403); + expect(res.body.error).toBe('CSRF token missing'); + }); + + it(`blocks ${method} with missing header token`, () => { + const next = vi.fn(); + const res = makeRes(); + verifyCsrf(makeReq(method, TOKEN, undefined), res, next); + expect(res.statusCode).toBe(403); + }); + } + + it('blocks when tokens have different lengths (timing-safe path)', () => { + const next = vi.fn(); + const res = makeRes(); + verifyCsrf(makeReq('POST', 'short', 'much-longer-token-here'), res, next); + expect(res.statusCode).toBe(403); + expect(res.body.error).toBe('CSRF token invalid'); + }); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..074249c --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,45 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Node environment for all tests (server-side CJS modules, no browser APIs needed) + environment: 'node', + // Global test helpers (describe, it, expect, vi) without per-file imports + globals: true, + // Run each test file in an isolated module registry so module-level state + // (tokenStore cache, config singletons) doesn't leak between files + isolate: true, + // Give each file its own data directory so tokenStore file I/O doesn't collide + setupFiles: ['./tests/setup.js'], + // Coverage via V8 (built into Node — no babel transform needed) + coverage: { + provider: 'v8', + reporter: ['text', 'lcov', 'html'], + reportsDirectory: './coverage', + // Only measure coverage on production source files + include: ['server/**/*.js'], + exclude: [ + 'server/index.js', // entry point with side-effects (process.exit, log streams) + 'node_modules/**', + 'tests/**', + 'coverage/**' + ], + // Per-file thresholds for the critical security/auth files we actively test. + // Overall project thresholds are lower because dashboard.js and poller.js + // are large files that require complex external service mocks (future work). + thresholds: { + lines: 25, + functions: 12, + branches: 12, + statements: 25, + perFile: false, + // Individual file thresholds for the files we DO test + 'server/app.js': { lines: 85, functions: 80, branches: 65, statements: 85 }, + 'server/routes/auth.js': { lines: 85, functions: 95, branches: 70, statements: 85 }, + 'server/middleware/requireAuth.js': { lines: 75, functions: 0, branches: 80, statements: 75 }, + 'server/utils/sanitizeError.js': { lines: 60, functions: 0, branches: 0, statements: 60 }, + 'server/utils/config.js': { lines: 50, functions: 30, branches: 55, statements: 50 } + } + } + } +});