diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fba2c1..53292f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: npm install --legacy-peer-deps diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9b166ed..3ee4e63 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: npm install --legacy-peer-deps diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml index 73dfb47..a8f02fc 100644 --- a/.github/workflows/harness.yml +++ b/.github/workflows/harness.yml @@ -34,7 +34,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: npm audit --audit-level=high # Fails on a high-or-critical advisory. Production-only because we # don't act on devDep advisories (no runtime exposure). @@ -48,7 +48,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: npm install --legacy-peer-deps --no-audit --no-fund - name: Run knip diff --git a/knip.json b/knip.json index dd426d3..e34aed0 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "entry": ["src/lib/components/SearchModal.svelte!"], - "ignoreDependencies": ["@cloudflare/workers-types"], + "ignoreDependencies": ["@cloudflare/workers-types", "cloudflare"], "rules": { "exports": "off", "types": "off", diff --git a/migrations/0002_add_alias.sql b/migrations/0002_add_alias.sql index 2d71ff8..8401c76 100644 --- a/migrations/0002_add_alias.sql +++ b/migrations/0002_add_alias.sql @@ -2,9 +2,15 @@ -- Created: 2024-02-01 -- Note: SQLite doesn't support "IF NOT EXISTS" for ALTER TABLE ADD COLUMN -- This migration may fail if already applied - that's expected behavior +-- +-- 2026 update: SQLite forbids `ADD COLUMN ... UNIQUE` (hosted D1 silently +-- tolerated this in the past, local Miniflare D1 rejects it strictly per +-- the SQLite spec). Replaced with the canonical pattern: +-- ADD COLUMN (no constraint) + CREATE UNIQUE INDEX. Equivalent uniqueness +-- guarantee, runs on both hosted and local D1. Safe to re-apply on prod: +-- this migration is already recorded in d1_migrations, so it will not +-- re-execute against the hosted DB. --- Add alias column (will fail if exists - that's OK) -ALTER TABLE configs ADD COLUMN alias TEXT UNIQUE; +ALTER TABLE configs ADD COLUMN alias TEXT; --- Create index (idempotent) -CREATE INDEX IF NOT EXISTS idx_configs_alias ON configs(alias); +CREATE UNIQUE INDEX IF NOT EXISTS idx_configs_alias_unique ON configs(alias); diff --git a/package-lock.json b/package-lock.json index 024dc13..df049bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,12 +17,13 @@ "@codemirror/view": "^6.43.0" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.16.6", "@eslint/js": "^10.0.1", "@sveltejs/adapter-cloudflare": "^7.2.6", "@sveltejs/kit": "^2.50.1", "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@vitest/coverage-v8": "^4.0.18", - "@vitest/ui": "^4.0.18", + "@vitest/coverage-v8": "^4.1.6", + "@vitest/ui": "^4.1.6", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.17.1", @@ -40,7 +41,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.59.3", "vite": "^7.3.1", - "vitest": "^4.0.18", + "vitest": "^4.1.6", "wrangler": "^4.61.1" } }, @@ -115,24 +116,24 @@ } }, "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", - "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", "dev": true, "license": "MIT OR Apache-2.0", "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@cloudflare/unenv-preset": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.12.0.tgz", - "integrity": "sha512-NK4vN+2Z/GbfGS4BamtbbVk1rcu5RmqaYGiyHJQrA09AoxdZPHDF3W/EhgI0YSK8p3vRo/VNCtbSJFPON7FWMQ==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", "dev": true, "license": "MIT OR Apache-2.0", "peerDependencies": { "unenv": "2.0.0-rc.24", - "workerd": "^1.20260115.0" + "workerd": ">1.20260305.0 <2.0.0-0" }, "peerDependenciesMeta": { "workerd": { @@ -140,10 +141,29 @@ } } }, + "node_modules/@cloudflare/vitest-pool-workers": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.16.6.tgz", + "integrity": "sha512-dt8LyDZCqJe95orS0eVmFnKFk7qkUTn1XjM2ppcTJJe67PJK9gt2VzBGk4gS64cxPk7uKoJ201kVtHPq5rQnNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cjs-module-lexer": "^1.2.3", + "esbuild": "0.27.3", + "miniflare": "4.20260515.0", + "wrangler": "4.92.0", + "zod": "^3.25.76" + }, + "peerDependencies": { + "@vitest/runner": "^4.1.0", + "@vitest/snapshot": "^4.1.0", + "vitest": "^4.1.0" + } + }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260128.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260128.0.tgz", - "integrity": "sha512-XJN8zWWNG3JwAUqqwMLNKJ9fZfdlQkx/zTTHW/BB8wHat9LjKD6AzxqCu432YmfjR+NxEKCzUOxMu1YOxlVxmg==", + "version": "1.20260515.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260515.1.tgz", + "integrity": "sha512-Wtw44el2pNbzixvTkWdfeBDTrQwQbJRz7/JUvPKV27I0pQWXbhNJPpM8cstq/pbrU5AGcA/HjFH6yPMRTIRKig==", "cpu": [ "x64" ], @@ -158,9 +178,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260128.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260128.0.tgz", - "integrity": "sha512-vKnRcmnm402GQ5DOdfT5H34qeR2m07nhnTtky8mTkNWP+7xmkz32AMdclwMmfO/iX9ncyKwSqmml2wPG32eq/w==", + "version": "1.20260515.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260515.1.tgz", + "integrity": "sha512-X8EqkZej6FfmhF9AVAQ3FhyQRr9acS4RcDunMU2YiuxKHF1IU8zzH3vY30/POaG+rUu9vGDp/VgUl49VPenHJQ==", "cpu": [ "arm64" ], @@ -175,9 +195,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260128.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260128.0.tgz", - "integrity": "sha512-RiaR+Qugof/c6oI5SagD2J5wJmIfI8wQWaV2Y9905Raj6sAYOFaEKfzkKnoLLLNYb4NlXicBrffJi1j7R/ypUA==", + "version": "1.20260515.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260515.1.tgz", + "integrity": "sha512-CDC89QxQ7Y7t7RG1Jd9vj/qolE1sQRkI2OSEuV5BMJi0vW/gV4OVG6xjpdK3b1OYnSWDzF7NpvlR5Yg86q7k4g==", "cpu": [ "x64" ], @@ -192,9 +212,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260128.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260128.0.tgz", - "integrity": "sha512-U39U9vcXLXYDbrJ112Q7D0LDUUnM54oXfAxPgrL2goBwio7Z6RnsM25TRvm+Q06F4+FeDOC4D51JXlFHb9t1OA==", + "version": "1.20260515.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260515.1.tgz", + "integrity": "sha512-WxbW/PToYES4fvHXzsr/5qOiETQs/Z9iZ0mjSZAiEwq5cMLZemzGN0COx+uFb9OvQwzh6Pg159qPFnw3+i9FuA==", "cpu": [ "arm64" ], @@ -209,9 +229,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260128.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260128.0.tgz", - "integrity": "sha512-fdJwSqRkJsAJFJ7+jy0th2uMO6fwaDA8Ny6+iFCssfzlNkc4dP/twXo+3F66FMLMe/6NIqjzVts0cpiv7ERYbQ==", + "version": "1.20260515.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260515.1.tgz", + "integrity": "sha512-WmV/iv+MHjYsvkcMVzpM2B5/mf06UUkdpVhZrtMfV9graWjBGPYFvE/eab8748RPVGKh1Xe1vXofLzDSwc08lA==", "cpu": [ "x64" ], @@ -336,9 +356,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -353,9 +373,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -370,9 +390,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -387,9 +407,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -404,9 +424,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -421,9 +441,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -438,9 +458,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -455,9 +475,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -472,9 +492,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -489,9 +509,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -506,9 +526,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -523,9 +543,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -540,9 +560,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -557,9 +577,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -574,9 +594,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -591,9 +611,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -608,9 +628,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -625,9 +645,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -642,9 +662,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -659,9 +679,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -676,9 +696,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -693,9 +713,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -710,9 +730,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -727,9 +747,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -744,9 +764,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -761,9 +781,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -2574,29 +2594,29 @@ "license": "ISC" }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", - "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "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.0.18", - "ast-v8-to-istanbul": "^0.3.10", + "@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.1", + "magicast": "^0.5.2", "obug": "^2.1.1", - "std-env": "^3.10.0", - "tinyrainbow": "^3.0.3" + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.18", - "vitest": "4.0.18" + "@vitest/browser": "4.1.6", + "vitest": "4.1.6" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2605,31 +2625,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "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.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@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.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "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.0.18", + "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2638,7 +2658,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -2650,26 +2670,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "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.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "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.0.18", + "@vitest/utils": "4.1.6", "pathe": "^2.0.3" }, "funding": { @@ -2677,13 +2697,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "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.0.18", + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2692,9 +2713,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "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": { @@ -2702,36 +2723,37 @@ } }, "node_modules/@vitest/ui": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.18.tgz", - "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.6.tgz", + "integrity": "sha512-wiu5em68DfGv/2HFvI1Njr7JI2CHcBlQvereSzVG8my53PRxjTNOCsD9VOkRKrsJBDHmyuXvosxWZw7T91a2mw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.6", "fflate": "^0.8.2", - "flatted": "^3.3.3", + "flatted": "^3.4.2", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.0.18" + "vitest": "4.1.6" } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "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.0.18", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.6", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2805,9 +2827,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", - "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "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": { @@ -2816,13 +2838,6 @@ "js-tokens": "^10.0.0" } }, - "node_modules/ast-v8-to-istanbul/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/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2973,6 +2988,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3021,6 +3043,13 @@ "dev": true, "license": "MIT" }, + "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.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -3165,16 +3194,16 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "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/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3185,32 +3214,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escape-string-regexp": { @@ -3584,9 +3613,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -3918,6 +3947,13 @@ "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/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -4258,16 +4294,16 @@ "license": "MIT" }, "node_modules/miniflare": { - "version": "4.20260128.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260128.0.tgz", - "integrity": "sha512-AVCn3vDRY+YXu1sP4mRn81ssno6VUqxo29uY2QVfgxXU2TMLvhRIoGwm7RglJ3Gzfuidit5R86CMQ6AvdFTGAw==", + "version": "4.20260515.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260515.0.tgz", + "integrity": "sha512-2j0oQWizk1Eu4Cm8tDX7Z+Nsjd0nebIj1TQcQ+Oy1QKeo0Ay9+bdn8wfLAtOj9znDCybDCUlnS1+nYvKXEdfNg==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", - "undici": "7.18.2", - "workerd": "1.20260128.0", + "undici": "7.24.8", + "workerd": "1.20260515.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, @@ -4275,7 +4311,7 @@ "miniflare": "bootstrap.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/minimatch": { @@ -5038,9 +5074,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "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" }, @@ -5208,9 +5244,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "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": { @@ -5311,9 +5347,9 @@ } }, "node_modules/undici": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", - "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", "dev": true, "license": "MIT", "engines": { @@ -5593,31 +5629,31 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "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.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@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": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -5633,12 +5669,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@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": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -5659,6 +5698,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -5667,6 +5712,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -5730,9 +5778,9 @@ } }, "node_modules/workerd": { - "version": "1.20260128.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260128.0.tgz", - "integrity": "sha512-EhLJGptSGFi8AEErLiamO3PoGpbRqL+v4Ve36H2B38VxmDgFOSmDhfepBnA14sCQzGf1AEaoZX2DCwZsmO74yQ==", + "version": "1.20260515.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260515.1.tgz", + "integrity": "sha512-MjKOJLcvU45xXedQowvuiHtJTxu4WTHYQeIlF7YmjuqhiI6dImTFxWCEoRQHiskztxuVSNEmdO7/0UfDu6OMnQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -5743,11 +5791,11 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260128.0", - "@cloudflare/workerd-darwin-arm64": "1.20260128.0", - "@cloudflare/workerd-linux-64": "1.20260128.0", - "@cloudflare/workerd-linux-arm64": "1.20260128.0", - "@cloudflare/workerd-windows-64": "1.20260128.0" + "@cloudflare/workerd-darwin-64": "1.20260515.1", + "@cloudflare/workerd-darwin-arm64": "1.20260515.1", + "@cloudflare/workerd-linux-64": "1.20260515.1", + "@cloudflare/workerd-linux-arm64": "1.20260515.1", + "@cloudflare/workerd-windows-64": "1.20260515.1" } }, "node_modules/worktop": { @@ -5765,33 +5813,33 @@ } }, "node_modules/wrangler": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.61.1.tgz", - "integrity": "sha512-hfYQ16VLPkNi8xE1/V3052S2stM5e+vq3Idpt83sXoDC3R7R1CLgMkK6M6+Qp3G+9GVDNyHCkvohMPdfFTaD4Q==", + "version": "4.92.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.92.0.tgz", + "integrity": "sha512-/DKpQHPxkuZbQsO9dFW2700VTD/4DSZMHjy92fO/frNoDRi/zQsFCAd2ONCV6TGqcUoXcP3D8Bo2gj/L4M0qQQ==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { - "@cloudflare/kv-asset-handler": "0.4.2", - "@cloudflare/unenv-preset": "2.12.0", + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", - "esbuild": "0.27.0", - "miniflare": "4.20260128.0", + "esbuild": "0.27.3", + "miniflare": "4.20260515.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260128.0" + "workerd": "1.20260515.1" }, "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260128.0" + "@cloudflare/workers-types": "^4.20260515.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -5799,490 +5847,6 @@ } } }, - "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", - "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/android-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", - "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/android-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", - "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/android-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", - "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", - "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", - "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", - "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", - "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", - "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", - "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", - "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", - "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", - "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", - "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", - "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", - "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", - "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", - "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", - "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", - "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", - "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", - "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", - "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", - "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", - "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/win32-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", - "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/esbuild": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", - "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.0", - "@esbuild/android-arm": "0.27.0", - "@esbuild/android-arm64": "0.27.0", - "@esbuild/android-x64": "0.27.0", - "@esbuild/darwin-arm64": "0.27.0", - "@esbuild/darwin-x64": "0.27.0", - "@esbuild/freebsd-arm64": "0.27.0", - "@esbuild/freebsd-x64": "0.27.0", - "@esbuild/linux-arm": "0.27.0", - "@esbuild/linux-arm64": "0.27.0", - "@esbuild/linux-ia32": "0.27.0", - "@esbuild/linux-loong64": "0.27.0", - "@esbuild/linux-mips64el": "0.27.0", - "@esbuild/linux-ppc64": "0.27.0", - "@esbuild/linux-riscv64": "0.27.0", - "@esbuild/linux-s390x": "0.27.0", - "@esbuild/linux-x64": "0.27.0", - "@esbuild/netbsd-arm64": "0.27.0", - "@esbuild/netbsd-x64": "0.27.0", - "@esbuild/openbsd-arm64": "0.27.0", - "@esbuild/openbsd-x64": "0.27.0", - "@esbuild/openharmony-arm64": "0.27.0", - "@esbuild/sunos-x64": "0.27.0", - "@esbuild/win32-arm64": "0.27.0", - "@esbuild/win32-ia32": "0.27.0", - "@esbuild/win32-x64": "0.27.0" - } - }, "node_modules/ws": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", @@ -6374,6 +5938,16 @@ "dev": true, "license": "MIT" }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 0214ae7..4161c1e 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,12 @@ "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "test": "vitest", + "test": "vitest run && vitest run --config vitest.workers.config.ts", "test:ui": "vitest --ui", - "test:run": "vitest run", - "test:coverage": "vitest run --coverage", + "test:run": "vitest run && vitest run --config vitest.workers.config.ts", + "test:happy-dom": "vitest run", + "test:workers": "vitest run --config vitest.workers.config.ts", + "test:coverage": "vitest run --coverage && vitest run --config vitest.workers.config.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", @@ -22,12 +24,13 @@ "install:hooks": "node scripts/install-hooks.mjs" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.16.6", "@eslint/js": "^10.0.1", "@sveltejs/adapter-cloudflare": "^7.2.6", "@sveltejs/kit": "^2.50.1", "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@vitest/coverage-v8": "^4.0.18", - "@vitest/ui": "^4.0.18", + "@vitest/coverage-v8": "^4.1.6", + "@vitest/ui": "^4.1.6", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.17.1", @@ -45,7 +48,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.59.3", "vite": "^7.3.1", - "vitest": "^4.0.18", + "vitest": "^4.1.6", "wrangler": "^4.61.1" }, "dependencies": { diff --git a/src/lib/server/auth.test.ts b/src/lib/server/auth.test.ts index 17196ca..cae969c 100644 --- a/src/lib/server/auth.test.ts +++ b/src/lib/server/auth.test.ts @@ -1,16 +1,39 @@ /** - * Tests for auth.ts - JWT signing/verification, getCurrentUser, slugify, generateId + * Tests for auth.ts — JWT signing/verification, getCurrentUser, slugify, generateId. + * Runs inside Workers runtime with real D1 (via vitest-pool-workers). */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; import { signToken, verifyToken, getCookie, getCurrentUser, slugify, generateId } from './auth'; -import { createMockDB } from '$lib/test/db-mock'; -import { mockUser, mockApiToken, createMockCookies, createMockRequest } from '$lib/test/fixtures'; +import { resetDb, seed, strip } from '$lib/test/seed'; +import { mockUser, mockApiToken } from '$lib/test/fixtures'; +const db = env.DB; const TEST_SECRET = 'test-jwt-secret-key-32-chars-long'; +const userRow = () => strip(mockUser, 'provider', 'provider_id'); + +function makeCookies(values: Record = {}) { + const store = { ...values }; + return { + get: (n: string) => store[n], + set: (n: string, v: string) => { + store[n] = v; + }, + delete: (n: string) => { + delete store[n]; + }, + getAll: () => Object.entries(store).map(([name, value]) => ({ name, value })), + serialize: () => '' + } as any; +} + +beforeEach(async () => { + await resetDb(db); +}); describe('signToken / verifyToken', () => { - it('should sign and verify a valid token', async () => { + it('signs and verifies a valid token', async () => { const payload = { userId: 'u1', username: 'alice', exp: Date.now() + 60_000 }; const token = await signToken(payload, TEST_SECRET); @@ -22,7 +45,7 @@ describe('signToken / verifyToken', () => { expect(result).toEqual(payload); }); - it('should reject token signed with wrong secret', async () => { + it('rejects token signed with wrong secret', async () => { const payload = { userId: 'u1', username: 'alice', exp: Date.now() + 60_000 }; const token = await signToken(payload, TEST_SECRET); @@ -30,7 +53,7 @@ describe('signToken / verifyToken', () => { expect(result).toBeNull(); }); - it('should reject expired token', async () => { + it('rejects expired token', async () => { const payload = { userId: 'u1', username: 'alice', exp: Date.now() - 1000 }; const token = await signToken(payload, TEST_SECRET); @@ -38,7 +61,7 @@ describe('signToken / verifyToken', () => { expect(result).toBeNull(); }); - it('should reject token without exp', async () => { + it('rejects token without exp', async () => { const payload = { userId: 'u1', username: 'alice', exp: 0 }; const token = await signToken(payload, TEST_SECRET); @@ -46,22 +69,21 @@ describe('signToken / verifyToken', () => { expect(result).toBeNull(); }); - it('should reject malformed token - no dot', async () => { + it('rejects malformed token — no dot', async () => { const result = await verifyToken('nodottoken', TEST_SECRET); expect(result).toBeNull(); }); - it('should reject malformed token - empty parts', async () => { + it('rejects malformed token — empty parts', async () => { const result = await verifyToken('.', TEST_SECRET); expect(result).toBeNull(); }); - it('should reject token with tampered data', async () => { + it('rejects token with tampered data', async () => { const payload = { userId: 'u1', username: 'alice', exp: Date.now() + 60_000 }; const token = await signToken(payload, TEST_SECRET); const [, sig] = token.split('.'); - // Tamper with data portion const tampered = { userId: 'u1', username: 'evil', exp: Date.now() + 60_000 }; const tamperedData = btoa(JSON.stringify(tampered)); @@ -69,220 +91,184 @@ describe('signToken / verifyToken', () => { expect(result).toBeNull(); }); - it('should reject token with invalid base64', async () => { + it('rejects token with invalid base64', async () => { const result = await verifyToken('not!valid!base64.also!not!valid', TEST_SECRET); expect(result).toBeNull(); }); }); describe('getCookie', () => { - it('should return cookie value when present', () => { - const cookies = createMockCookies({ session: 'abc123' }); - expect(getCookie(cookies, 'session')).toBe('abc123'); + it('returns cookie value when present', () => { + expect(getCookie(makeCookies({ session: 'abc123' }), 'session')).toBe('abc123'); }); - it('should return undefined when cookie missing', () => { - const cookies = createMockCookies({}); - expect(getCookie(cookies, 'session')).toBeUndefined(); + it('returns undefined when cookie missing', () => { + expect(getCookie(makeCookies({}), 'session')).toBeUndefined(); }); }); describe('getCurrentUser', () => { - it('should authenticate via Bearer obt_ API token', async () => { - const db = createMockDB({ - users: [mockUser], - api_tokens: [mockApiToken] - }); + it('authenticates via Bearer obt_ API token', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); - const request = createMockRequest({ + const request = new Request('http://x/', { headers: { Authorization: `Bearer ${mockApiToken.token}` } }); - const cookies = createMockCookies({}); - - const user = await getCurrentUser(request, cookies, db as any, TEST_SECRET); - expect(user).toBeDefined(); + const user = await getCurrentUser(request, makeCookies(), db, TEST_SECRET); + expect(user).not.toBeNull(); expect((user as any).username).toBe('testuser'); }); - it('should update last_used_at on valid API token auth', async () => { - const db = createMockDB({ - users: [mockUser], - api_tokens: [mockApiToken] - }); + it('updates last_used_at on valid API token auth', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); - const request = createMockRequest({ + const request = new Request('http://x/', { headers: { Authorization: `Bearer ${mockApiToken.token}` } }); - const cookies = createMockCookies({}); + await getCurrentUser(request, makeCookies(), db, TEST_SECRET); - await getCurrentUser(request, cookies, db as any, TEST_SECRET); - // If it got here without error, the UPDATE ran successfully - expect(true).toBe(true); + const row = await db + .prepare('SELECT last_used_at FROM api_tokens WHERE id = ?') + .bind(mockApiToken.id) + .first<{ last_used_at: string | null }>(); + expect(row?.last_used_at).toBeTruthy(); }); - it('should return null for invalid API token', async () => { - const db = createMockDB({ - users: [mockUser], - api_tokens: [mockApiToken] - }); + it('returns null for invalid API token', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); - const request = createMockRequest({ + const request = new Request('http://x/', { headers: { Authorization: 'Bearer obt_nonexistent_token_value_here' } }); - const cookies = createMockCookies({}); - - const user = await getCurrentUser(request, cookies, db as any, TEST_SECRET); + const user = await getCurrentUser(request, makeCookies(), db, TEST_SECRET); expect(user).toBeNull(); }); - it('should return null for expired API token', async () => { + it('returns null for expired API token', async () => { const expiredToken = { ...mockApiToken, id: 'tok_expired', token: 'obt_expired1234567890abcdefghijklmnopqr', expires_at: '2020-01-01T00:00:00Z' }; - const db = createMockDB({ - users: [mockUser], - api_tokens: [expiredToken] - }); + await seed(db, { users: [userRow()], api_tokens: [expiredToken] }); - const request = createMockRequest({ + const request = new Request('http://x/', { headers: { Authorization: `Bearer ${expiredToken.token}` } }); - const cookies = createMockCookies({}); - - const user = await getCurrentUser(request, cookies, db as any, TEST_SECRET); + const user = await getCurrentUser(request, makeCookies(), db, TEST_SECRET); expect(user).toBeNull(); }); - it('should return null for API token with non-existent user', async () => { - const orphanToken = { - ...mockApiToken, - user_id: 'nonexistent_user_id' - }; - const db = createMockDB({ - users: [mockUser], - api_tokens: [orphanToken] - }); + it('returns null for API token with non-existent user', async () => { + // Orphan token isn't actually possible with FK enforcement — D1 rejects the insert. + // Cover the same code path by deleting the user after the token exists. + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); + await db.prepare('DELETE FROM users WHERE id = ?').bind(mockUser.id).run(); - const request = createMockRequest({ - headers: { Authorization: `Bearer ${orphanToken.token}` } + const request = new Request('http://x/', { + headers: { Authorization: `Bearer ${mockApiToken.token}` } }); - const cookies = createMockCookies({}); - - const user = await getCurrentUser(request, cookies, db as any, TEST_SECRET); + const user = await getCurrentUser(request, makeCookies(), db, TEST_SECRET); expect(user).toBeNull(); }); - it('should authenticate via session cookie with valid JWT', async () => { + it('authenticates via session cookie with valid JWT', async () => { + await seed(db, { users: [userRow()] }); const payload = { userId: mockUser.id, username: mockUser.username, exp: Date.now() + 60_000 }; const jwt = await signToken(payload, TEST_SECRET); - const db = createMockDB({ users: [mockUser] }); - const request = createMockRequest({}); - const cookies = createMockCookies({ session: jwt }); - - const user = await getCurrentUser(request, cookies, db as any, TEST_SECRET); - expect(user).toBeDefined(); + const request = new Request('http://x/'); + const user = await getCurrentUser(request, makeCookies({ session: jwt }), db, TEST_SECRET); + expect(user).not.toBeNull(); expect((user as any).username).toBe('testuser'); }); - it('should return null when no session cookie', async () => { - const db = createMockDB({ users: [mockUser] }); - const request = createMockRequest({}); - const cookies = createMockCookies({}); + it('returns null when no session cookie', async () => { + await seed(db, { users: [userRow()] }); - const user = await getCurrentUser(request, cookies, db as any, TEST_SECRET); + const user = await getCurrentUser(new Request('http://x/'), makeCookies(), db, TEST_SECRET); expect(user).toBeNull(); }); - it('should return null for invalid session JWT', async () => { - const db = createMockDB({ users: [mockUser] }); - const request = createMockRequest({}); - const cookies = createMockCookies({ session: 'invalid.jwt.token' }); + it('returns null for invalid session JWT', async () => { + await seed(db, { users: [userRow()] }); - const user = await getCurrentUser(request, cookies, db as any, TEST_SECRET); + const user = await getCurrentUser( + new Request('http://x/'), + makeCookies({ session: 'invalid.jwt.token' }), + db, + TEST_SECRET + ); expect(user).toBeNull(); }); - it('should return null for expired session JWT', async () => { + it('returns null for expired session JWT', async () => { + await seed(db, { users: [userRow()] }); const payload = { userId: mockUser.id, username: mockUser.username, exp: Date.now() - 1000 }; const jwt = await signToken(payload, TEST_SECRET); - const db = createMockDB({ users: [mockUser] }); - const request = createMockRequest({}); - const cookies = createMockCookies({ session: jwt }); - - const user = await getCurrentUser(request, cookies, db as any, TEST_SECRET); + const user = await getCurrentUser(new Request('http://x/'), makeCookies({ session: jwt }), db, TEST_SECRET); expect(user).toBeNull(); }); - it('should prefer API token over session cookie', async () => { + it('prefers API token over session cookie', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); const payload = { userId: mockUser.id, username: mockUser.username, exp: Date.now() + 60_000 }; const jwt = await signToken(payload, TEST_SECRET); - const db = createMockDB({ - users: [mockUser], - api_tokens: [mockApiToken] - }); - - const request = createMockRequest({ + const request = new Request('http://x/', { headers: { Authorization: `Bearer ${mockApiToken.token}` } }); - const cookies = createMockCookies({ session: jwt }); - - const user = await getCurrentUser(request, cookies, db as any, TEST_SECRET); - expect(user).toBeDefined(); + const user = await getCurrentUser(request, makeCookies({ session: jwt }), db, TEST_SECRET); + expect(user).not.toBeNull(); expect((user as any).username).toBe('testuser'); }); - it('should skip non-obt Bearer tokens and fall through to cookie', async () => { - const db = createMockDB({ users: [mockUser] }); - const request = createMockRequest({ + it('skips non-obt Bearer tokens and falls through to cookie', async () => { + await seed(db, { users: [userRow()] }); + + const request = new Request('http://x/', { headers: { Authorization: 'Bearer some-regular-jwt-token' } }); - const cookies = createMockCookies({}); - - const user = await getCurrentUser(request, cookies, db as any, TEST_SECRET); + const user = await getCurrentUser(request, makeCookies(), db, TEST_SECRET); expect(user).toBeNull(); }); }); describe('slugify', () => { - it('should lowercase and replace non-alphanumeric chars', () => { + it('lowercases and replaces non-alphanumeric chars', () => { expect(slugify('Hello World')).toBe('hello-world'); }); - it('should collapse multiple hyphens', () => { + it('collapses multiple hyphens', () => { expect(slugify('a---b')).toBe('a-b'); }); - it('should trim leading/trailing hyphens', () => { + it('trims leading/trailing hyphens', () => { expect(slugify('--hello--')).toBe('hello'); }); - it('should handle special characters', () => { + it('handles special characters', () => { expect(slugify('My Config! @2024')).toBe('my-config-2024'); }); - it('should truncate to 50 characters', () => { - const long = 'a'.repeat(60); - expect(slugify(long).length).toBe(50); + it('truncates to 50 characters', () => { + expect(slugify('a'.repeat(60)).length).toBe(50); }); - it('should handle empty string', () => { + it('handles empty string', () => { expect(slugify('')).toBe(''); }); }); describe('generateId', () => { - it('should return a valid UUID', () => { + it('returns a valid UUID', () => { const id = generateId(); expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); }); - it('should generate unique IDs', () => { + it('generates unique IDs', () => { const ids = new Set(Array.from({ length: 10 }, () => generateId())); expect(ids.size).toBe(10); }); diff --git a/src/lib/server/db/configs.test.ts b/src/lib/server/db/configs.test.ts index f8459b7..3beb8c8 100644 --- a/src/lib/server/db/configs.test.ts +++ b/src/lib/server/db/configs.test.ts @@ -1,59 +1,74 @@ /** - * Unit tests for revision-related db functions in configs.ts. - * - * Uses an in-memory mock DB. The mock's DELETE handler has been extended in - * db-mock.ts to support the NOT IN subquery used by the pruning step inside - * saveRevision — see executeQuery's "NOT IN subquery" branch. + * Tests for revision-related db functions in configs.ts. + * Runs inside the Workers runtime with a real local D1 (via vitest-pool-workers). */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; import { saveRevision, listRevisions, getRevision, restoreConfigToRevision } from './configs'; -import { createMockDB } from '$lib/test/db-mock'; +import { resetDb, seed } from '$lib/test/seed'; import { mockConfig, mockRevision, mockRevisionOlder } from '$lib/test/fixtures'; -// ── saveRevision ───────────────────────────────────────────────────────────── +const db = env.DB; + +// Strip mock-only fields (e.g. package_count) from revision fixtures — +// the real schema doesn't have them; json_array_length() computes them. +function asRevisionRow(r: typeof mockRevision | typeof mockRevisionOlder) { + const { package_count: _omit, ...rest } = r; + return rest; +} + +const userRow = { id: mockConfig.user_id, username: 'testuser', email: 't@test.com' }; + +beforeEach(async () => { + await resetDb(db); +}); describe('saveRevision', () => { it('inserts a new revision into config_revisions', async () => { - const db = createMockDB({ configs: [mockConfig] }); + await seed(db, { users: [userRow], configs: [mockConfig] }); const packages = JSON.stringify([{ name: 'git', type: 'formula' }]); await saveRevision(db, mockConfig.id, packages, null); - const revisions = db.data.config_revisions; - expect(revisions).toHaveLength(1); - expect(revisions[0].config_id).toBe(mockConfig.id); - expect(revisions[0].packages).toBe(packages); - expect(revisions[0].message).toBeNull(); - expect(revisions[0].id).toMatch(/^rev_/); + const { results } = await db + .prepare('SELECT * FROM config_revisions WHERE config_id = ?') + .bind(mockConfig.id) + .all<{ id: string; config_id: string; packages: string; message: string | null }>(); + expect(results).toHaveLength(1); + expect(results[0].config_id).toBe(mockConfig.id); + expect(results[0].packages).toBe(packages); + expect(results[0].message).toBeNull(); + expect(results[0].id).toMatch(/^rev_/); }); it('stores message when provided', async () => { - const db = createMockDB({ configs: [mockConfig] }); + await seed(db, { users: [userRow], configs: [mockConfig] }); - await saveRevision( - db, - mockConfig.id, - JSON.stringify([{ name: 'git', type: 'formula' }]), - 'before adding rust' - ); + await saveRevision(db, mockConfig.id, JSON.stringify([{ name: 'git', type: 'formula' }]), 'before adding rust'); - expect(db.data.config_revisions[0].message).toBe('before adding rust'); + const row = await db + .prepare('SELECT message FROM config_revisions WHERE config_id = ?') + .bind(mockConfig.id) + .first<{ message: string }>(); + expect(row?.message).toBe('before adding rust'); }); it('generates a unique id for each revision', async () => { - const db = createMockDB({ configs: [mockConfig] }); + await seed(db, { users: [userRow], configs: [mockConfig] }); const pkgs = JSON.stringify([]); await saveRevision(db, mockConfig.id, pkgs, null); await saveRevision(db, mockConfig.id, pkgs, null); - const ids = db.data.config_revisions.map((r: any) => r.id); - expect(new Set(ids).size).toBe(2); + const { results } = await db + .prepare('SELECT id FROM config_revisions WHERE config_id = ?') + .bind(mockConfig.id) + .all<{ id: string }>(); + expect(new Set(results.map((r: { id: string }) => r.id)).size).toBe(2); }); it('keeps at most 10 revisions per config (prunes oldest)', async () => { - // Seed 10 existing revisions with distinct created_at timestamps const existing = Array.from({ length: 10 }, (_, i) => ({ id: `rev_existing${String(i).padStart(2, '0')}`, config_id: mockConfig.id, @@ -61,18 +76,19 @@ describe('saveRevision', () => { message: null, created_at: `2026-01-${String(i + 1).padStart(2, '0')} 00:00:00` })); + await seed(db, { users: [userRow], configs: [mockConfig], config_revisions: existing }); - const db = createMockDB({ configs: [mockConfig], config_revisions: existing }); - - // The 11th save should trigger pruning await saveRevision(db, mockConfig.id, JSON.stringify([{ name: 'new', type: 'formula' }]), 'eleventh'); - expect(db.data.config_revisions.length).toBeLessThanOrEqual(10); + const { results } = await db + .prepare('SELECT id FROM config_revisions WHERE config_id = ?') + .bind(mockConfig.id) + .all(); + expect(results.length).toBeLessThanOrEqual(10); }); it('does not prune revisions belonging to other configs', async () => { - const otherConfig = { ...mockConfig, id: 'cfg_other' }; - + const otherConfig = { ...mockConfig, id: 'cfg_other', slug: 'cfg-other', alias: null }; const existing = Array.from({ length: 10 }, (_, i) => ({ id: `rev_mine${String(i).padStart(2, '0')}`, config_id: mockConfig.id, @@ -80,7 +96,6 @@ describe('saveRevision', () => { message: null, created_at: `2026-01-${String(i + 1).padStart(2, '0')} 00:00:00` })); - const other = { id: 'rev_other01', config_id: otherConfig.id, @@ -88,29 +103,25 @@ describe('saveRevision', () => { message: null, created_at: '2026-01-01 00:00:00' }; - - const db = createMockDB({ + await seed(db, { + users: [userRow], configs: [mockConfig, otherConfig], config_revisions: [...existing, other] }); await saveRevision(db, mockConfig.id, JSON.stringify([]), 'overflow'); - // The other config's revision must survive - const otherStillPresent = db.data.config_revisions.some( - (r: any) => r.id === 'rev_other01' - ); - expect(otherStillPresent).toBe(true); + const survivor = await db.prepare('SELECT id FROM config_revisions WHERE id = ?').bind('rev_other01').first(); + expect(survivor).not.toBeNull(); }); }); -// ── listRevisions ───────────────────────────────────────────────────────────── - describe('listRevisions', () => { it('returns revisions for the given config', async () => { - const db = createMockDB({ + await seed(db, { + users: [userRow], configs: [mockConfig], - config_revisions: [mockRevision, mockRevisionOlder] + config_revisions: [asRevisionRow(mockRevision), asRevisionRow(mockRevisionOlder)] }); const result = await listRevisions(db, mockConfig.id); @@ -119,7 +130,7 @@ describe('listRevisions', () => { }); it('returns an empty array when there are no revisions', async () => { - const db = createMockDB({ configs: [mockConfig] }); + await seed(db, { users: [userRow], configs: [mockConfig] }); const result = await listRevisions(db, mockConfig.id); @@ -127,10 +138,12 @@ describe('listRevisions', () => { }); it('does not return revisions from other configs', async () => { - const otherRevision = { ...mockRevision, id: 'rev_other', config_id: 'cfg_other' }; - const db = createMockDB({ - configs: [mockConfig], - config_revisions: [mockRevision, otherRevision] + const otherConfig = { ...mockConfig, id: 'cfg_other', slug: 'cfg-other', alias: null }; + const otherRevision = { ...asRevisionRow(mockRevision), id: 'rev_other', config_id: 'cfg_other' }; + await seed(db, { + users: [userRow], + configs: [mockConfig, otherConfig], + config_revisions: [asRevisionRow(mockRevision), otherRevision] }); const result = await listRevisions(db, mockConfig.id); @@ -139,9 +152,10 @@ describe('listRevisions', () => { }); it('includes id, message, and created_at fields', async () => { - const db = createMockDB({ + await seed(db, { + users: [userRow], configs: [mockConfig], - config_revisions: [mockRevision] + config_revisions: [asRevisionRow(mockRevision)] }); const result = await listRevisions(db, mockConfig.id); @@ -153,16 +167,14 @@ describe('listRevisions', () => { }); }); -// ── getRevision ─────────────────────────────────────────────────────────────── - describe('getRevision', () => { it('returns the revision when it exists and belongs to the config', async () => { - const db = createMockDB({ + await seed(db, { + users: [userRow], configs: [mockConfig], - config_revisions: [mockRevision] + config_revisions: [asRevisionRow(mockRevision)] }); - // getRevision signature: (db, revisionId, configId) const result = await getRevision(db, mockRevision.id, mockConfig.id); expect(result).not.toBeNull(); @@ -170,7 +182,7 @@ describe('getRevision', () => { }); it('returns null when the revision id does not exist', async () => { - const db = createMockDB({ configs: [mockConfig] }); + await seed(db, { users: [userRow], configs: [mockConfig] }); const result = await getRevision(db, 'rev_nonexistent', mockConfig.id); @@ -178,8 +190,11 @@ describe('getRevision', () => { }); it('returns null when revision belongs to a different config', async () => { - const wrongConfigRevision = { ...mockRevision, config_id: 'cfg_other' }; - const db = createMockDB({ + const otherConfig = { ...mockConfig, id: 'cfg_other', slug: 'cfg-other', alias: null }; + const wrongConfigRevision = { ...asRevisionRow(mockRevision), config_id: 'cfg_other' }; + await seed(db, { + users: [userRow], + configs: [mockConfig, otherConfig], config_revisions: [wrongConfigRevision] }); @@ -189,39 +204,43 @@ describe('getRevision', () => { }); }); -// ── restoreConfigToRevision ─────────────────────────────────────────────────── - describe('restoreConfigToRevision', () => { it('updates the config packages to the revision packages', async () => { - const db = createMockDB({ + await seed(db, { + users: [userRow], configs: [mockConfig], - config_revisions: [mockRevision] + config_revisions: [asRevisionRow(mockRevision)] }); await restoreConfigToRevision(db, mockConfig.id, mockRevision.id); - const updated = db.data.configs.find((c: any) => c.id === mockConfig.id); - expect(updated.packages).toBe(mockRevision.packages); + const updated = await db + .prepare('SELECT packages FROM configs WHERE id = ?') + .bind(mockConfig.id) + .first<{ packages: string }>(); + expect(updated?.packages).toBe(mockRevision.packages); }); it('saves the current packages as a "before restore" revision before overwriting', async () => { - const db = createMockDB({ + await seed(db, { + users: [userRow], configs: [mockConfig], - config_revisions: [mockRevision] + config_revisions: [asRevisionRow(mockRevision)] }); const packagesBefore = mockConfig.packages; await restoreConfigToRevision(db, mockConfig.id, mockRevision.id); - const beforeRevision = db.data.config_revisions.find((r: any) => - r.message?.includes('before restore') - ); - expect(beforeRevision).toBeDefined(); - expect(beforeRevision.packages).toBe(packagesBefore); + const beforeRev = await db + .prepare(`SELECT packages, message FROM config_revisions WHERE config_id = ? AND message LIKE 'before restore%'`) + .bind(mockConfig.id) + .first<{ packages: string; message: string }>(); + expect(beforeRev).not.toBeNull(); + expect(beforeRev!.packages).toBe(packagesBefore); }); it('returns null when the revision does not exist', async () => { - const db = createMockDB({ configs: [mockConfig] }); + await seed(db, { users: [userRow], configs: [mockConfig] }); const result = await restoreConfigToRevision(db, mockConfig.id, 'rev_nonexistent'); diff --git a/src/lib/test/apply-migrations.ts b/src/lib/test/apply-migrations.ts new file mode 100644 index 0000000..beec6f8 --- /dev/null +++ b/src/lib/test/apply-migrations.ts @@ -0,0 +1,13 @@ +import { applyD1Migrations, env, type D1Migration } from 'cloudflare:test'; +import type { D1Database } from '@cloudflare/workers-types'; + +declare module 'cloudflare:test' { + interface ProvidedEnv { + DB: D1Database; + TEST_MIGRATIONS: D1Migration[]; + JWT_SECRET: string; + APP_URL: string; + } +} + +await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); diff --git a/src/lib/test/call.ts b/src/lib/test/call.ts new file mode 100644 index 0000000..d4a004a --- /dev/null +++ b/src/lib/test/call.ts @@ -0,0 +1,60 @@ +import { env } from 'cloudflare:test'; + +type Handler = (event: any) => Response | Promise; + +type CallOpts = { + url: string; + method?: string; + token?: string; + headers?: Record; + body?: unknown | string; + cookies?: Record; + route?: { id: string }; + clientAddress?: string; + params?: Record; +}; + +/** + * Minimal RequestEvent builder for invoking SvelteKit `+server.ts` handlers + * directly inside the Workers runtime, with `env` from `cloudflare:test`. + */ +export function call(handler: Handler, opts: CallOpts): Promise { + const headers: Record = { ...(opts.headers ?? {}) }; + if (opts.token) headers['authorization'] = `Bearer ${opts.token}`; + if (opts.body !== undefined && typeof opts.body !== 'string' && !headers['content-type']) { + headers['content-type'] = 'application/json'; + } + + const body = + opts.body === undefined ? undefined : typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body); + + const request = new Request(opts.url, { method: opts.method ?? 'GET', headers, body }); + const cookieStore = { ...(opts.cookies ?? {}) }; + const cookies = { + get: (n: string) => cookieStore[n], + set: (n: string, v: string) => { + cookieStore[n] = v; + }, + delete: (n: string) => { + delete cookieStore[n]; + }, + getAll: () => Object.entries(cookieStore).map(([name, value]) => ({ name, value })), + serialize: () => '' + }; + + return Promise.resolve( + handler({ + request, + platform: { env }, + url: new URL(opts.url), + route: opts.route ?? { id: '' }, + params: opts.params ?? {}, + locals: {}, + isDataRequest: false, + isSubRequest: false, + cookies, + getClientAddress: () => opts.clientAddress ?? '127.0.0.1', + fetch: globalThis.fetch + }) + ); +} diff --git a/src/lib/test/db-mock.ts b/src/lib/test/db-mock.ts deleted file mode 100644 index 3405642..0000000 --- a/src/lib/test/db-mock.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** - * D1 Database mock for testing - * - * Usage: - * const db = createMockDB({ users: [mockUser], configs: [mockConfig] }); - */ - -import type { D1Database, D1PreparedStatement, D1Result } from '@cloudflare/workers-types'; - -export type MockData = { - users?: any[]; - configs?: any[]; - api_tokens?: any[]; - cli_auth_codes?: any[]; - config_revisions?: any[]; -}; - -/** - * Creates a mock D1Database with pre-seeded data - */ -export function createMockDB(data: MockData = {}): D1Database & { data: Record } { - const tables = { - users: data.users || [], - configs: data.configs || [], - api_tokens: data.api_tokens || [], - cli_auth_codes: data.cli_auth_codes || [], - config_revisions: data.config_revisions || [] - }; - - return { - data: tables, - prepare(sql: string) { - return createMockStatement(sql, tables); - }, - dump() { - throw new Error('dump() not implemented in mock'); - }, - async batch(statements: D1PreparedStatement[]): Promise[]> { - const results: D1Result[] = []; - for (const stmt of statements) { - results.push(await stmt.run() as D1Result); - } - return results; - }, - exec(query: string): Promise { - throw new Error('exec() not implemented in mock'); - }, - withSession() { - throw new Error('withSession() not implemented in mock'); - } - } as unknown as D1Database & { data: Record }; -} - -function createMockStatement(sql: string, tables: Record): D1PreparedStatement { - let bindings: any[] = []; - - const statement = { - bind(...values: any[]) { - bindings = values; - return statement; - }, - - async first(colName?: string): Promise { - const result = await statement.all(); - if (!result.results || result.results.length === 0) return null; - if (colName) { - return (result.results[0] as any)[colName] || null; - } - return result.results[0]; - }, - - async all(): Promise> { - const results = executeQuery(sql, bindings, tables); - return { - results: results || [], - success: true, - meta: { - duration: 0.1, - size_after: 0, - rows_read: results.length, - rows_written: 0, - changed_db: false, - changes: 0, - last_row_id: 0 - } - }; - }, - - async run(): Promise> { - // For INSERT/UPDATE/DELETE - modified array length indicates change count - const modified = executeQuery(sql, bindings, tables); - const changes = Array.isArray(modified) ? modified.length : 0; - return { - results: [], - success: true, - meta: { - duration: 0.1, - size_after: 0, - rows_read: 0, - rows_written: changes ? 1 : 0, - changed_db: changes > 0, - changes, - last_row_id: changes ? 1 : 0 - } - }; - }, - - async raw(): Promise { - const result = await statement.all(); - return result.results || []; - } - } as D1PreparedStatement; - - return statement; -} - -/** - * Simple SQL query executor for common patterns - * This is a simplified implementation - extend as needed - */ -function executeQuery(sql: string, bindings: any[], tables: Record): T[] { - const sqlLower = sql.toLowerCase().trim(); - - // SELECT queries - if (sqlLower.startsWith('select')) { - // Handle COUNT(*) queries - const countMatch = sql.match(/select\s+count\(\*\)\s+as\s+(\w+)/i); - if (countMatch) { - const tableMatch = sql.match(/from\s+(\w+)/i); - if (tableMatch) { - const tableName = tableMatch[1]; - let results = [...(tables[tableName] || [])]; - - // Apply WHERE filters for COUNT - const whereMatch = sql.match(/where\s+(\w+)\s*=\s*\?/i); - if (whereMatch && bindings.length > 0) { - const fieldName = whereMatch[1]; - const value = bindings[0]; - results = results.filter((row) => row[fieldName] === value); - } - - const countField = countMatch[1]; - return [{ [countField]: results.length }] as T[]; - } - } - - // Extract table name - const tableMatch = sql.match(/from\s+(\w+)/i); - if (!tableMatch) return []; - - const tableName = tableMatch[1]; - let results = [...(tables[tableName] || [])]; - - // WHERE clause - simple field = value matching - const whereMatch = sql.match(/where\s+(\w+)\s*=\s*\?/i); - if (whereMatch && bindings.length > 0) { - const fieldName = whereMatch[1]; - const value = bindings[0]; - results = results.filter((row) => row[fieldName] === value); - } - - // WHERE clause - multiple conditions (AND) - const whereMultiMatch = sql.match(/where\s+(.*?)(?:order|limit|$)/i); - if (whereMultiMatch) { - const conditions = whereMultiMatch[1].split(/\s+and\s+/i); - let bindingIndex = 0; - - conditions.forEach((condition) => { - // Equality with binding: field = ? - const eqMatch = condition.match(/(\w+)\s*=\s*\?/); - if (eqMatch && bindingIndex < bindings.length) { - const fieldName = eqMatch[1]; - const value = bindings[bindingIndex++]; - results = results.filter((row) => row[fieldName] === value); - return; - } - - // Datetime comparison: field > datetime('now') - const datetimeGtMatch = condition.match(/(\w+)\s*>\s*datetime\(['"]now['"]\)/i); - if (datetimeGtMatch) { - const fieldName = datetimeGtMatch[1]; - const now = new Date().toISOString(); - results = results.filter((row) => { - const fieldValue = row[fieldName]; - return fieldValue && fieldValue > now; - }); - return; - } - - // Datetime comparison: field < datetime('now') - const datetimeLtMatch = condition.match(/(\w+)\s*<\s*datetime\(['"]now['"]\)/i); - if (datetimeLtMatch) { - const fieldName = datetimeLtMatch[1]; - const now = new Date().toISOString(); - results = results.filter((row) => { - const fieldValue = row[fieldName]; - return fieldValue && fieldValue < now; - }); - } - }); - } - - // LIMIT clause - const limitMatch = sql.match(/limit\s+(\d+)/i); - if (limitMatch) { - const limit = parseInt(limitMatch[1]); - results = results.slice(0, limit); - } - - return results as T[]; - } - - // INSERT queries - if (sqlLower.startsWith('insert')) { - const tableMatch = sql.match(/insert\s+into\s+(\w+)/i); - if (tableMatch) { - const tableName = tableMatch[1]; - const fieldsMatch = sql.match(/\((.*?)\)\s*values/is); - const valuesMatch = sql.match(/values\s*\((.*?)\)/is); - - if (fieldsMatch && valuesMatch) { - const fields = fieldsMatch[1].split(',').map((f) => f.trim()); - const values = valuesMatch[1].split(',').map((v) => v.trim()); - - const newRow: any = {}; - let bindingIndex = 0; - - fields.forEach((field, i) => { - const value = values[i]; - - if (value === '?') { - newRow[field] = bindings[bindingIndex++]; - } else if (value.startsWith("'") && value.endsWith("'")) { - newRow[field] = value.slice(1, -1); - } else if (value.match(/datetime\(['"]now['"]\s*,\s*['"](.*?)['"]\)/i)) { - const offsetMatch = value.match(/datetime\(['"]now['"]\s*,\s*['"]([^'"]+)['"]\)/i); - if (offsetMatch) { - const now = new Date(); - const offset = offsetMatch[1]; - - const minutesMatch = offset.match(/\+(\d+)\s*minutes?/i); - const daysMatch = offset.match(/\+(\d+)\s*days?/i); - - if (minutesMatch) { - now.setMinutes(now.getMinutes() + parseInt(minutesMatch[1])); - } else if (daysMatch) { - now.setDate(now.getDate() + parseInt(daysMatch[1])); - } - - newRow[field] = now.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ''); - } - } else if (value.match(/datetime\(['"]now['"]\)/i)) { - newRow[field] = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ''); - } - }); - - tables[tableName].push(newRow); - return [newRow] as T[]; - } - } - return [] as T[]; - } - - // UPDATE queries - if (sqlLower.startsWith('update')) { - const tableMatch = sql.match(/update\s+(\w+)/i); - if (!tableMatch) return [] as T[]; - - const tableName = tableMatch[1]; - const setMatch = sql.match(/set\s+(.*?)\s+where/i); - const whereClause = sql.match(/where\s+(.*?)$/i); - - if (setMatch && whereClause) { - // Count SET bindings to determine where WHERE bindings start - const setFields = setMatch[1].split(',').map((s) => s.trim()); - let setBindingCount = 0; - setFields.forEach((field) => { - if (field.match(/=\s*\?/)) setBindingCount++; - }); - - // Parse WHERE conditions - const conditions = whereClause[1].split(/\s+and\s+/i); - let whereBindingIndex = setBindingCount; - - const matchesRow = (row: any): boolean => { - for (const condition of conditions) { - const eqMatch = condition.match(/(\w+)\s*=\s*\?/); - if (eqMatch) { - if (row[eqMatch[1]] !== bindings[whereBindingIndex++]) return false; - continue; - } - - const literalEqMatch = condition.match(/(\w+)\s*=\s*'([^']*)'/); - if (literalEqMatch) { - if (row[literalEqMatch[1]] !== literalEqMatch[2]) return false; - continue; - } - - const datetimeGtMatch = condition.match(/(\w+)\s*>\s*datetime\(['"]now['"]\)/i); - if (datetimeGtMatch) { - const fieldValue = row[datetimeGtMatch[1]]; - if (!fieldValue || fieldValue <= new Date().toISOString()) return false; - continue; - } - } - return true; - }; - - let changeCount = 0; - tables[tableName].forEach((row) => { - // Reset whereBindingIndex for each row - whereBindingIndex = setBindingCount; - if (matchesRow(row)) { - let bindingIndex = 0; - setFields.forEach((field) => { - const bindingMatchField = field.match(/(\w+)\s*=\s*\?/); - if (bindingMatchField && bindingIndex < setBindingCount) { - row[bindingMatchField[1]] = bindings[bindingIndex++]; - return; - } - - const literalMatch = field.match(/(\w+)\s*=\s*'([^']*)'/); - if (literalMatch) { - row[literalMatch[1]] = literalMatch[2]; - } - - const datetimeMatch = field.match(/(\w+)\s*=\s*datetime\(['"]now['"]\)/i); - if (datetimeMatch) { - row[datetimeMatch[1]] = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ''); - } - }); - changeCount++; - } - }); - // Return array with one element per changed row to signal change count - return new Array(changeCount).fill({}) as T[]; - } - return [] as T[]; - } - - // DELETE queries - if (sqlLower.startsWith('delete')) { - const tableMatch = sql.match(/delete\s+from\s+(\w+)/i); - if (!tableMatch) return [] as T[]; - - const tableName = tableMatch[1]; - - // NOT IN subquery (revision pruning pattern): - // DELETE FROM t WHERE filterField = ? AND idField NOT IN (SELECT idField FROM t WHERE filterField = ? ORDER BY orderField DESC LIMIT ?) - if (sqlLower.includes('not in')) { - const filterFieldMatch = sql.match(/where\s+(\w+)\s*=\s*\?/i); - const idFieldMatch = sql.match(/and\s+(\w+)\s+not\s+in/i); - const orderFieldMatch = sql.match(/order\s+by\s+(\w+)\s+desc/i); - - if (filterFieldMatch && idFieldMatch && orderFieldMatch && bindings.length >= 3) { - const filterField = filterFieldMatch[1]; - const idField = idFieldMatch[1]; - const orderField = orderFieldMatch[1]; - const filterValue = bindings[0]; - const limit = Number(bindings[2]); - - // Sort matching rows newest-first and keep top `limit` ids - const allForFilter = tables[tableName].filter((row: any) => row[filterField] === filterValue); - allForFilter.sort((a: any, b: any) => (b[orderField] > a[orderField] ? 1 : -1)); - const keepIds = new Set(allForFilter.slice(0, limit).map((r: any) => r[idField])); - - const before = tables[tableName].length; - tables[tableName] = tables[tableName].filter( - (row: any) => row[filterField] !== filterValue || keepIds.has(row[idField]) - ); - const deleted = before - tables[tableName].length; - return new Array(deleted).fill({}) as T[]; - } - } - - // Simple WHERE field = ? pattern - const whereMatch = sql.match(/where\s+(\w+)\s*=\s*\?/i); - if (whereMatch && bindings.length > 0) { - const fieldName = whereMatch[1]; - const value = bindings[0]; - const index = tables[tableName].findIndex((row) => row[fieldName] === value); - if (index !== -1) { - tables[tableName].splice(index, 1); - return [{}] as T[]; - } - } - return [] as T[]; - } - - return [] as T[]; -} diff --git a/src/lib/test/fixtures.ts b/src/lib/test/fixtures.ts index 56f8aa9..94acd68 100644 --- a/src/lib/test/fixtures.ts +++ b/src/lib/test/fixtures.ts @@ -1,5 +1,10 @@ /** - * Test fixtures - mock data for testing + * Test fixtures — mock row data for seeding the test D1. + * + * NOTE: some fields here (e.g. `mockUser.provider`, `mockRevision.package_count`) + * don't exist in the real schema and must be stripped before insertion via the + * `strip()` helper in `$lib/test/seed`. These are legacy artifacts from the old + * SQL-parsing mock DB that we keep so call sites read naturally. */ export const mockUser = { @@ -64,24 +69,7 @@ export const mockExpiredApiToken = { ...mockApiToken, id: 'tok_expired', token: 'obt_expired1234567890abcdefghijklmnopqr', - expires_at: '2025-01-01T00:00:00Z' // Expired -}; - -export const mockCliAuthCode = { - code: 'ABCD1234', - user_id: null, - token: null, - status: 'pending' as const, - expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(), // 5 minutes from now - created_at: new Date().toISOString() -}; - -export const mockApprovedCliAuthCode = { - ...mockCliAuthCode, - code: 'APPROVED1', - user_id: 'user_test123', - token: 'obt_cli_approved_token_123456789abcdefgh', - status: 'approved' as const + expires_at: '2025-01-01T00:00:00Z' }; export const mockRevision = { @@ -91,7 +79,6 @@ export const mockRevision = { { name: 'git', type: 'formula' }, { name: 'docker', type: 'cask' } ]), - // package_count is pre-computed so the mock returns it for json_array_length() queries package_count: 2, message: 'before adding rust', created_at: '2026-01-10 10:00:00' @@ -105,116 +92,3 @@ export const mockRevisionOlder = { message: null, created_at: '2026-01-05 09:00:00' }; - -export const mockExpiredCliAuthCode = { - ...mockCliAuthCode, - code: 'EXPIRED1', - status: 'expired' as const, - expires_at: '2025-01-01T00:00:00Z' -}; - -/** - * Helper to create a mock Cookies object - */ -export function createMockCookies(cookies: Record = {}): any { - return { - get: (name: string) => cookies[name], - set: (name: string, value: string) => { - cookies[name] = value; - }, - delete: (name: string) => { - delete cookies[name]; - }, - getAll: () => Object.entries(cookies).map(([name, value]) => ({ name, value })) - }; -} - -/** - * Helper to create a mock Request object - */ -export function createMockRequest(options: { - method?: string; - url?: string; - headers?: Record; - body?: any; - cookies?: Record; - invalidJSON?: boolean; - clientIp?: string; -}): Request { - const { - method = 'GET', - url = 'http://localhost:5173', - headers = {}, - body = null, - cookies = {}, - invalidJSON = false, - clientIp - } = options; - - const headersInit = new Headers(headers); - - if (clientIp && !headersInit.has('cf-connecting-ip')) { - headersInit.set('cf-connecting-ip', clientIp); - } - - if (Object.keys(cookies).length > 0) { - const cookieString = Object.entries(cookies) - .map(([key, value]) => `${key}=${value}`) - .join('; '); - headersInit.set('cookie', cookieString); - } - - const init: RequestInit = { - method, - headers: headersInit - }; - - if (body && method !== 'GET' && method !== 'HEAD') { - if (invalidJSON) { - init.body = body; - } else { - init.body = typeof body === 'string' ? body : JSON.stringify(body); - } - if (!headersInit.has('content-type')) { - headersInit.set('content-type', 'application/json'); - } - } - - return new Request(url, init); -} - -/** - * Helper to create mock platform env - */ -export function createMockPlatform(db?: any): App.Platform { - return { - env: { - DB: db || null, - JWT_SECRET: 'test-jwt-secret-key-32-chars-long', - GITHUB_CLIENT_ID: 'test-github-client-id', - GITHUB_CLIENT_SECRET: 'test-github-client-secret', - GOOGLE_CLIENT_ID: 'test-google-client-id', - GOOGLE_CLIENT_SECRET: 'test-google-client-secret', - APP_URL: 'http://localhost:5173' - } - } as App.Platform; -} - -/** - * Helper to create a mock SvelteKit RequestEvent for testing route handlers. - * Returns `any` to avoid strict route-id type constraints in tests. - */ -export function createMockRequestEvent(db: any, overrides: Record = {}): any { - return { - platform: createMockPlatform(db), - url: new URL('http://localhost:5173'), - route: { id: '' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '127.0.0.1', - fetch: globalThis.fetch, - ...overrides - }; -} diff --git a/src/lib/test/helpers.ts b/src/lib/test/helpers.ts deleted file mode 100644 index d1abbca..0000000 --- a/src/lib/test/helpers.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Test helper functions - */ - -import { signToken } from '$lib/server/auth'; -import type { D1Database } from '@cloudflare/workers-types'; - -/** - * Creates a valid JWT token for testing - */ -export async function createTestJWT(userId: string, username: string): Promise { - return await signToken( - { - userId, - username, - exp: Date.now() + 3600 * 1000 - }, - 'test-jwt-secret-key-32-chars-long' - ); -} - -/** - * Creates a Bearer token string - */ -export function createBearerToken(token: string): string { - return `Bearer ${token}`; -} - -/** - * Extracts JSON from Response - */ -export async function getJSON(response: Response): Promise { - return await response.json(); -} - -/** - * Assert response status - */ -export function assertStatus(response: Response, expectedStatus: number) { - if (response.status !== expectedStatus) { - throw new Error( - `Expected status ${expectedStatus}, got ${response.status}. ` + - `Response: ${response.statusText}` - ); - } -} - -/** - * Assert response has error - */ -export async function assertError(response: Response, expectedMessage?: string) { - const json = await getJSON(response); - if (!json.error) { - throw new Error('Expected error property in response'); - } - if (expectedMessage && !json.error.includes(expectedMessage)) { - throw new Error(`Expected error to include "${expectedMessage}", got: ${json.error}`); - } -} - -/** - * Wait for a promise to resolve with a timeout - */ -export function withTimeout(promise: Promise, ms: number): Promise { - return Promise.race([ - promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms) - ) - ]); -} - -/** - * Sleep for testing async flows - */ -export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Generate random string for testing - */ -export function randomString(length: number = 8): string { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - let result = ''; - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; -} - -/** - * Query helper that safely extracts data from D1Database - */ -export async function queryFirst( - db: D1Database, - sql: string, - ...bindings: any[] -): Promise { - const result = await db.prepare(sql).bind(...bindings).first(); - return result; -} - -/** - * Query helper that safely extracts all rows from D1Database - */ -export async function queryAll( - db: D1Database, - sql: string, - ...bindings: any[] -): Promise { - const result = await db.prepare(sql).bind(...bindings).all(); - return result.results || []; -} diff --git a/src/lib/test/seed.ts b/src/lib/test/seed.ts new file mode 100644 index 0000000..958c268 --- /dev/null +++ b/src/lib/test/seed.ts @@ -0,0 +1,41 @@ +import type { D1Database } from '@cloudflare/workers-types'; + +// Strip keys that don't match the real schema (test fixtures sometimes carry +// extra fields the old SQL-parsing mock tolerated). +export function strip(obj: T, ...keys: (keyof T)[]): T { + const out = { ...obj } as T; + for (const k of keys) delete out[k]; + return out; +} + +const TABLES_IN_DELETE_ORDER = ['config_revisions', 'api_tokens', 'cli_auth_codes', 'configs', 'users'] as const; + +export async function resetDb(db: D1Database): Promise { + for (const t of TABLES_IN_DELETE_ORDER) { + await db.prepare(`DELETE FROM ${t}`).run(); + } +} + +export type SeedRows = { + users?: Record[]; + configs?: Record[]; + config_revisions?: Record[]; + api_tokens?: Record[]; + cli_auth_codes?: Record[]; +}; + +export async function seed(db: D1Database, rows: SeedRows): Promise { + for (const table of ['users', 'configs', 'config_revisions', 'api_tokens', 'cli_auth_codes'] as const) { + const data = rows[table]; + if (!data?.length) continue; + for (const row of data) { + const cols = Object.keys(row); + const placeholders = cols.map(() => '?').join(', '); + const values = cols.map((c) => row[c]); + await db + .prepare(`INSERT INTO ${table} (${cols.join(', ')}) VALUES (${placeholders})`) + .bind(...values) + .run(); + } + } +} diff --git a/src/lib/test/setup.ts b/src/lib/test/setup.ts deleted file mode 100644 index b02e951..0000000 --- a/src/lib/test/setup.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Vitest global setup file - * Runs before all test files - */ - -import { expect } from 'vitest'; - -// Add custom matchers if needed -// expect.extend({ ... }); - -// Mock environment variables for tests -process.env.JWT_SECRET = 'test-jwt-secret-key-32-chars-long'; -process.env.GITHUB_CLIENT_ID = 'test-github-client-id'; -process.env.GITHUB_CLIENT_SECRET = 'test-github-client-secret'; -process.env.GOOGLE_CLIENT_ID = 'test-google-client-id'; -process.env.GOOGLE_CLIENT_SECRET = 'test-google-client-secret'; diff --git a/src/routes/[username]/[slug]/config/server.test.ts b/src/routes/[username]/[slug]/config/server.test.ts index 6e1031e..b29690a 100644 --- a/src/routes/[username]/[slug]/config/server.test.ts +++ b/src/routes/[username]/[slug]/config/server.test.ts @@ -1,930 +1,426 @@ /** - * Tests for config JSON endpoint - * Critical: Private config access control via Bearer tokens + * Tests for config JSON endpoint. + * Runs inside Workers runtime with real D1 (via vitest-pool-workers). */ -import { describe, it, expect } from 'vitest'; -import { GET as _GET } from './+server'; -const GET = _GET as (event: any) => Promise; -import { createMockDB } from '$lib/test/db-mock'; -import { - mockUser, - mockPublicConfig, - mockPrivateConfig, - mockApiToken, - mockExpiredApiToken, - createMockRequest, - createMockPlatform -} from '$lib/test/fixtures'; -import { createBearerToken, getJSON } from '$lib/test/helpers'; - -describe('[username]/[slug]/config GET - Visibility Auth', () => { - const baseUrl = 'http://localhost:5173/testuser/my-config/config'; - - describe('Public configs', () => { - it('should return config JSON without auth', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockPublicConfig] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toContain('application/json'); - - const json = await getJSON(response); - expect(json.username).toBe('testuser'); - expect(json.slug).toBe('public-config'); - expect(json.name).toBe('Public Config'); - expect(json.preset).toBe('developer'); - expect(json.packages).toBeDefined(); - }); +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import { GET } from './+server'; +import { resetDb, seed, strip } from '$lib/test/seed'; +import { call } from '$lib/test/call'; +import { mockUser, mockPublicConfig, mockPrivateConfig, mockApiToken, mockExpiredApiToken } from '$lib/test/fixtures'; + +const db = env.DB; +const baseUrl = 'http://localhost:5173/testuser/my-config/config'; +const userRow = () => strip(mockUser, 'provider', 'provider_id'); + +const opts = (overrides: Record = {}) => ({ + url: baseUrl, + route: { id: '/[username]/[slug]/config' }, + params: { username: 'testuser', slug: 'my-config' }, + ...overrides +}); + +const seedPublic = (config: Record) => seed(db, { users: [userRow()], configs: [config] }); + +beforeEach(async () => { + await resetDb(db); +}); + +describe('public configs', () => { + it('returns config JSON without auth', async () => { + await seedPublic(mockPublicConfig); + + const response = await call(GET, opts({ params: { username: 'testuser', slug: 'public-config' } })); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + const json = (await response.json()) as Record; + expect(json.username).toBe('testuser'); + expect(json.slug).toBe('public-config'); + expect(json.name).toBe('Public Config'); + expect(json.preset).toBe('developer'); + expect(json.packages).toBeDefined(); }); +}); - describe('Private configs - Auth required', () => { - it('should reject private config without auth header', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockPrivateConfig] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'private-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(403); - const json = await getJSON(response); - expect(json.error).toContain('Config is private'); - }); +describe('private configs — auth required', () => { + it('rejects private config without auth header', async () => { + await seedPublic(mockPrivateConfig); - it('should reject private config with invalid token', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockPrivateConfig], - api_tokens: [mockApiToken] - }); - - const request = createMockRequest({ - url: baseUrl, - headers: { authorization: createBearerToken('obt_invalid_token_123') } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'private-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(403); - }); + const response = await call(GET, opts({ params: { username: 'testuser', slug: 'private-config' } })); - it('should reject private config with expired token', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockPrivateConfig], - api_tokens: [mockExpiredApiToken] - }); - - const request = createMockRequest({ - url: baseUrl, - headers: { authorization: createBearerToken(mockExpiredApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'private-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(403); - }); + expect(response.status).toBe(403); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Config is private'); + }); - it('should return config JSON with valid owner token', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockPrivateConfig], - api_tokens: [mockApiToken] - }); - - const request = createMockRequest({ - url: baseUrl, - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'private-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(200); - - const json = await getJSON(response); - expect(json.username).toBe('testuser'); - expect(json.slug).toBe('private-config'); - expect(json.name).toBe('Private Config'); - expect(json.preset).toBe('developer'); - expect(json.packages).toBeDefined(); - expect(json.casks).toBeDefined(); - expect(json.taps).toBeDefined(); - expect(json.npm).toBeDefined(); + it('rejects private config with invalid token', async () => { + await seed(db, { + users: [userRow()], + configs: [mockPrivateConfig], + api_tokens: [mockApiToken] }); + + const response = await call( + GET, + opts({ params: { username: 'testuser', slug: 'private-config' }, token: 'obt_invalid_token_123' }) + ); + + expect(response.status).toBe(403); }); - describe('Data parsing', () => { - it('should parse packages correctly', async () => { - const config = { - ...mockPublicConfig, - packages: JSON.stringify([ - { name: 'git', type: 'formula', desc: 'Version control' }, - { name: 'visual-studio-code', type: 'cask', desc: 'Editor' }, - { name: 'typescript', type: 'npm', desc: 'TypeScript' } - ]) - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - expect(json.packages).toContainEqual(expect.objectContaining({ name: 'git' })); - expect(json.packages.map((p: any) => p.name)).not.toContain('visual-studio-code'); - expect(json.casks).toContainEqual(expect.objectContaining({ name: 'visual-studio-code' })); - expect(json.npm).toContainEqual(expect.objectContaining({ name: 'typescript' })); + it('rejects private config with expired token', async () => { + await seed(db, { + users: [userRow()], + configs: [mockPrivateConfig], + api_tokens: [mockExpiredApiToken] }); - it('should parse snapshot for taps and casks', async () => { - const config = { - ...mockPublicConfig, - snapshot: JSON.stringify({ - packages: { - taps: ['homebrew/cask-fonts'], - casks: ['font-fira-code'] - } - }) - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - expect(json.taps).toContain('homebrew/cask-fonts'); - }); + const response = await call( + GET, + opts({ params: { username: 'testuser', slug: 'private-config' }, token: mockExpiredApiToken.token }) + ); - it('should not include shell in API response', async () => { - const config = { - ...mockPublicConfig, - snapshot: JSON.stringify({ - packages: { taps: [], casks: [] }, - shell: { - oh_my_zsh: true, - theme: 'powerlevel10k', - plugins: ['git', 'zsh-autosuggestions'] - } - }) - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - expect(json.shell).toBeUndefined(); - }); + expect(response.status).toBe(403); + }); - it('should parse macos_prefs from snapshot', async () => { - const config = { - ...mockPublicConfig, - snapshot: JSON.stringify({ - packages: { taps: [], casks: [] }, - macos_prefs: [ - { domain: 'NSGlobalDomain', key: 'AppleShowAllExtensions', value: 'true', desc: 'Show file extensions' }, - { domain: 'com.apple.dock', key: 'autohide', value: 'true', desc: 'Auto-hide dock' } - ] - }) - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - expect(json.macos_prefs).toHaveLength(2); - expect(json.macos_prefs[0].domain).toBe('NSGlobalDomain'); - expect(json.macos_prefs[1].key).toBe('autohide'); + it('returns config JSON with valid owner token', async () => { + await seed(db, { + users: [userRow()], + configs: [mockPrivateConfig], + api_tokens: [mockApiToken] }); - it('should preserve host="currentHost" for ByHost prefs and omit it when empty', async () => { - const config = { - ...mockPublicConfig, - snapshot: JSON.stringify({ - packages: { taps: [], casks: [] }, - macos_prefs: [ - // ByHost pref — must round-trip `host` so the CLI uses `defaults -currentHost`. - { domain: 'com.apple.controlcenter', key: 'Sound', type: 'int', value: '18', desc: 'Sound dropdown', host: 'currentHost' }, - // Main-domain pref — no `host` field in input, must not gain one in output. - { domain: 'NSGlobalDomain', key: 'AppleShowAllExtensions', type: 'bool', value: 'true', desc: 'Show extensions' } - ] - }) - }; - - const db = createMockDB({ users: [mockUser], configs: [config] }); - const platform = createMockPlatform(db); - - const response = await GET({ - request: createMockRequest({ url: baseUrl }), - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - expect(json.macos_prefs).toHaveLength(2); - expect(json.macos_prefs[0].host).toBe('currentHost'); - expect(json.macos_prefs[1]).not.toHaveProperty('host'); - }); + const response = await call( + GET, + opts({ params: { username: 'testuser', slug: 'private-config' }, token: mockApiToken.token }) + ); + + expect(response.status).toBe(200); + const json = (await response.json()) as Record; + expect(json.username).toBe('testuser'); + expect(json.slug).toBe('private-config'); + expect(json.name).toBe('Private Config'); + expect(json.preset).toBe('developer'); + expect(json.packages).toBeDefined(); + expect(json.casks).toBeDefined(); + expect(json.taps).toBeDefined(); + expect(json.npm).toBeDefined(); + }); - it('should filter out invalid macos_prefs entries', async () => { - const config = { - ...mockPublicConfig, - snapshot: JSON.stringify({ - packages: { taps: [], casks: [] }, - macos_prefs: [ - { domain: 'NSGlobalDomain', key: 'AppleShowAllExtensions', value: 'true', desc: 'Valid' }, - { domain: 'bad', key: 123 }, - null, - 'not-an-object' - ] - }) - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - expect(json.macos_prefs).toHaveLength(1); - expect(json.macos_prefs[0].domain).toBe('NSGlobalDomain'); + it('rejects private config with token belonging to different user', async () => { + const otherUser = { ...userRow(), id: 'other_user_id', username: 'otheruser' }; + const otherToken = { + ...mockApiToken, + id: 'tok_other', + user_id: 'other_user_id', + token: 'obt_otheruser1234567890abcdefghijklmnop' + }; + await seed(db, { + users: [userRow(), otherUser], + configs: [mockPrivateConfig], + api_tokens: [otherToken] }); - it('should return null macos_prefs when empty array', async () => { - const config = { - ...mockPublicConfig, - snapshot: JSON.stringify({ - packages: { taps: [], casks: [] }, - macos_prefs: [] - }) - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - expect(json.macos_prefs).toBeNull(); - }); + const response = await call( + GET, + opts({ params: { username: 'testuser', slug: 'private-config' }, token: otherToken.token }) + ); - it('should handle invalid snapshot JSON gracefully', async () => { - const config = { - ...mockPublicConfig, - snapshot: '{invalid json' - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.shell).toBeUndefined(); - expect(json.macos_prefs).toBeNull(); - }); + expect(response.status).toBe(403); + }); +}); - it('should handle string packages (legacy format)', async () => { - const config = { - ...mockPublicConfig, - packages: JSON.stringify(['git', 'curl', 'wget']), - snapshot: JSON.stringify({ - packages: { taps: [], casks: ['curl'] } - }) - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - expect(json.packages.map((p: any) => p.name)).toEqual(['git', 'wget']); - expect(json.casks).toContainEqual(expect.objectContaining({ name: 'curl' })); +describe('data parsing', () => { + const publicOpts = opts({ params: { username: 'testuser', slug: 'public-config' } }); + const callPublic = () => call(GET, publicOpts); + + it('routes packages by type (formula/cask/npm)', async () => { + await seedPublic({ + ...mockPublicConfig, + packages: JSON.stringify([ + { name: 'git', type: 'formula', desc: 'Version control' }, + { name: 'visual-studio-code', type: 'cask', desc: 'Editor' }, + { name: 'typescript', type: 'npm', desc: 'TypeScript' } + ]) }); - it('should extract taps from fully-qualified package names', async () => { - const config = { - ...mockPublicConfig, - packages: JSON.stringify([ - { name: 'homebrew/cask-fonts/font-fira-code', type: 'formula', desc: 'Font' } - ]) - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - expect(json.taps).toContain('homebrew/cask-fonts'); + const json = (await (await callPublic()).json()) as { + packages: { name: string }[]; + casks: { name: string }[]; + npm: { name: string }[]; + }; + expect(json.packages).toContainEqual(expect.objectContaining({ name: 'git' })); + expect(json.packages.map((p) => p.name)).not.toContain('visual-studio-code'); + expect(json.casks).toContainEqual(expect.objectContaining({ name: 'visual-studio-code' })); + expect(json.npm).toContainEqual(expect.objectContaining({ name: 'typescript' })); + }); + + it('parses snapshot for taps and casks', async () => { + await seedPublic({ + ...mockPublicConfig, + snapshot: JSON.stringify({ + packages: { taps: ['homebrew/cask-fonts'], casks: ['font-fira-code'] } + }) }); - it('should parse custom_script into post_install lines', async () => { - const config = { - ...mockPublicConfig, - custom_script: 'echo "line1"\n\necho "line2"\n \necho "line3"' - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - expect(json.post_install).toEqual(['echo "line1"', 'echo "line2"', 'echo "line3"']); + const json = (await (await callPublic()).json()) as { taps: string[] }; + expect(json.taps).toContain('homebrew/cask-fonts'); + }); + + it('does not include shell in API response', async () => { + await seedPublic({ + ...mockPublicConfig, + snapshot: JSON.stringify({ + packages: { taps: [], casks: [] }, + shell: { oh_my_zsh: true, theme: 'powerlevel10k', plugins: ['git', 'zsh-autosuggestions'] } + }) }); - it('should return empty post_install when no custom_script', async () => { - const config = { - ...mockPublicConfig, - custom_script: null - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - expect(json.post_install).toEqual([]); + const json = (await (await callPublic()).json()) as { shell?: unknown }; + expect(json.shell).toBeUndefined(); + }); + + it('parses macos_prefs from snapshot', async () => { + await seedPublic({ + ...mockPublicConfig, + snapshot: JSON.stringify({ + packages: { taps: [], casks: [] }, + macos_prefs: [ + { domain: 'NSGlobalDomain', key: 'AppleShowAllExtensions', value: 'true', desc: 'Show extensions' }, + { domain: 'com.apple.dock', key: 'autohide', value: 'true', desc: 'Auto-hide dock' } + ] + }) }); - it('should return empty dotfiles_repo when not set', async () => { - const config = { - ...mockPublicConfig, - dotfiles_repo: null - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - expect(json.dotfiles_repo).toBe(''); + const json = (await (await callPublic()).json()) as { macos_prefs: { domain: string; key: string }[] }; + expect(json.macos_prefs).toHaveLength(2); + expect(json.macos_prefs[0].domain).toBe('NSGlobalDomain'); + expect(json.macos_prefs[1].key).toBe('autohide'); + }); + + it('preserves host="currentHost" and omits it when empty', async () => { + await seedPublic({ + ...mockPublicConfig, + snapshot: JSON.stringify({ + packages: { taps: [], casks: [] }, + macos_prefs: [ + { domain: 'com.apple.controlcenter', key: 'Sound', type: 'int', value: '18', desc: 'd', host: 'currentHost' }, + { domain: 'NSGlobalDomain', key: 'AppleShowAllExtensions', type: 'bool', value: 'true', desc: 'd' } + ] + }) }); - it('should handle typed objects with desc (from-snapshot format)', async () => { - const config = { - ...mockPublicConfig, - packages: JSON.stringify([ - { name: 'git', type: 'formula', desc: 'Version control' }, - { name: 'docker', type: 'cask', desc: 'Containers' }, - { name: 'typescript', type: 'npm', desc: 'Typed JS' }, - { name: 'homebrew/cask-fonts', type: 'tap' } - ]) - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - // Formulae: git + tap entry - expect(json.packages).toContainEqual({ name: 'git', desc: 'Version control' }); - expect(json.packages).toContainEqual(expect.objectContaining({ name: 'homebrew/cask-fonts' })); - expect(json.casks).toContainEqual({ name: 'docker', desc: 'Containers' }); - expect(json.npm).toContainEqual({ name: 'typescript', desc: 'Typed JS' }); + const json = (await (await callPublic()).json()) as { macos_prefs: { host?: string }[] }; + expect(json.macos_prefs).toHaveLength(2); + expect(json.macos_prefs[0].host).toBe('currentHost'); + expect(json.macos_prefs[1]).not.toHaveProperty('host'); + }); + + it('filters out invalid macos_prefs entries', async () => { + await seedPublic({ + ...mockPublicConfig, + snapshot: JSON.stringify({ + packages: { taps: [], casks: [] }, + macos_prefs: [ + { domain: 'NSGlobalDomain', key: 'AppleShowAllExtensions', value: 'true', desc: 'Valid' }, + { domain: 'bad', key: 123 }, + null, + 'not-an-object' + ] + }) }); - it('should handle typed objects without desc (fills from metadata)', async () => { - const config = { - ...mockPublicConfig, - packages: JSON.stringify([ - { name: 'curl', type: 'formula' }, - { name: 'warp', type: 'cask' } - ]) - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - // curl is in package-metadata.ts, so desc should be filled - const curlPkg = json.packages.find((p: any) => p.name === 'curl'); - expect(curlPkg).toBeDefined(); - expect(curlPkg.desc).toBeTruthy(); - expect(curlPkg.desc).not.toBe('curl'); // should be a real description + const json = (await (await callPublic()).json()) as { macos_prefs: { domain: string }[] }; + expect(json.macos_prefs).toHaveLength(1); + expect(json.macos_prefs[0].domain).toBe('NSGlobalDomain'); + }); + + it('returns null macos_prefs when empty array', async () => { + await seedPublic({ + ...mockPublicConfig, + snapshot: JSON.stringify({ + packages: { taps: [], casks: [] }, + macos_prefs: [] + }) }); - it('should handle mixed format (strings + typed objects)', async () => { - const config = { - ...mockPublicConfig, - packages: JSON.stringify([ - 'git', - { name: 'docker', type: 'cask' }, - 'wget' - ]), - snapshot: JSON.stringify({ - packages: { taps: [], casks: [] } - }) - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - // Strings become formulae, typed objects route by type - expect(json.packages.map((p: any) => p.name)).toContain('git'); - expect(json.packages.map((p: any) => p.name)).toContain('wget'); - expect(json.casks).toContainEqual(expect.objectContaining({ name: 'docker' })); - // All entries are objects with name and desc - for (const pkg of [...json.packages, ...json.casks]) { - expect(pkg).toHaveProperty('name'); - expect(pkg).toHaveProperty('desc'); - } + const json = (await (await callPublic()).json()) as { macos_prefs: unknown }; + expect(json.macos_prefs).toBeNull(); + }); + + it('handles invalid snapshot JSON gracefully', async () => { + await seedPublic({ ...mockPublicConfig, snapshot: '{invalid json' }); + + const response = await callPublic(); + expect(response.status).toBe(200); + const json = (await response.json()) as { shell?: unknown; macos_prefs: unknown }; + expect(json.shell).toBeUndefined(); + expect(json.macos_prefs).toBeNull(); + }); + + it('handles string packages (legacy format)', async () => { + await seedPublic({ + ...mockPublicConfig, + packages: JSON.stringify(['git', 'curl', 'wget']), + snapshot: JSON.stringify({ packages: { taps: [], casks: ['curl'] } }) }); - it('should handle empty packages', async () => { - const config = { - ...mockPublicConfig, - packages: '[]' - }; - - const db = createMockDB({ - users: [mockUser], - configs: [config] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const json = await getJSON(response); - expect(json.packages).toEqual([]); - expect(json.casks).toEqual([]); - expect(json.npm).toEqual([]); - expect(json.taps).toEqual([]); + const json = (await (await callPublic()).json()) as { + packages: { name: string }[]; + casks: { name: string }[]; + }; + expect(json.packages.map((p) => p.name)).toEqual(['git', 'wget']); + expect(json.casks).toContainEqual(expect.objectContaining({ name: 'curl' })); + }); + + it('extracts taps from fully-qualified package names', async () => { + await seedPublic({ + ...mockPublicConfig, + packages: JSON.stringify([{ name: 'homebrew/cask-fonts/font-fira-code', type: 'formula', desc: 'Font' }]) }); + + const json = (await (await callPublic()).json()) as { taps: string[] }; + expect(json.taps).toContain('homebrew/cask-fonts'); }); - describe('Error handling', () => { - it('should return 500 when platform env missing', async () => { - const request = createMockRequest({ url: baseUrl }); - const platform = { env: undefined } as any; - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(500); - const json = await getJSON(response); - expect(json.error).toContain('Platform env not available'); + it('parses custom_script into post_install lines', async () => { + await seedPublic({ + ...mockPublicConfig, + custom_script: 'echo "line1"\n\necho "line2"\n \necho "line3"' }); - it('should return 404 when user not found', async () => { - const db = createMockDB({ users: [], configs: [] }); - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'nonexistent', slug: 'config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(404); - const json = await getJSON(response); - expect(json.error).toContain('User not found'); + const json = (await (await callPublic()).json()) as { post_install: string[] }; + expect(json.post_install).toEqual(['echo "line1"', 'echo "line2"', 'echo "line3"']); + }); + + it('returns empty post_install when no custom_script', async () => { + await seedPublic({ ...mockPublicConfig, custom_script: null }); + + const json = (await (await callPublic()).json()) as { post_install: unknown[] }; + expect(json.post_install).toEqual([]); + }); + + it('returns empty dotfiles_repo when not set', async () => { + await seedPublic({ ...mockPublicConfig, dotfiles_repo: null }); + + const json = (await (await callPublic()).json()) as { dotfiles_repo: string }; + expect(json.dotfiles_repo).toBe(''); + }); + + it('handles typed objects with desc (from-snapshot format)', async () => { + await seedPublic({ + ...mockPublicConfig, + packages: JSON.stringify([ + { name: 'git', type: 'formula', desc: 'Version control' }, + { name: 'docker', type: 'cask', desc: 'Containers' }, + { name: 'typescript', type: 'npm', desc: 'Typed JS' }, + { name: 'homebrew/cask-fonts', type: 'tap' } + ]) }); - it('should return 404 when config not found', async () => { - const db = createMockDB({ users: [mockUser], configs: [] }); - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'nonexistent' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(404); - const json = await getJSON(response); - expect(json.error).toContain('Config not found'); + const json = (await (await callPublic()).json()) as { + packages: { name: string; desc?: string }[]; + casks: { name: string; desc: string }[]; + npm: { name: string; desc: string }[]; + }; + expect(json.packages).toContainEqual({ name: 'git', desc: 'Version control' }); + expect(json.packages).toContainEqual(expect.objectContaining({ name: 'homebrew/cask-fonts' })); + expect(json.casks).toContainEqual({ name: 'docker', desc: 'Containers' }); + expect(json.npm).toContainEqual({ name: 'typescript', desc: 'Typed JS' }); + }); + + it('handles typed objects without desc (fills from metadata)', async () => { + await seedPublic({ + ...mockPublicConfig, + packages: JSON.stringify([ + { name: 'curl', type: 'formula' }, + { name: 'warp', type: 'cask' } + ]) }); - it('should reject private config with token belonging to different user', async () => { - const otherUserToken = { - ...mockApiToken, - id: 'tok_other', - user_id: 'other_user_id', - token: 'obt_otheruser1234567890abcdefghijklmnop' - }; - const db = createMockDB({ - users: [mockUser], - configs: [mockPrivateConfig], - api_tokens: [otherUserToken] - }); - - const request = createMockRequest({ - url: baseUrl, - headers: { authorization: createBearerToken(otherUserToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'private-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/config' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(403); + const json = (await (await callPublic()).json()) as { + packages: { name: string; desc: string }[]; + }; + const curlPkg = json.packages.find((p) => p.name === 'curl'); + expect(curlPkg).toBeDefined(); + expect(curlPkg!.desc).toBeTruthy(); + expect(curlPkg!.desc).not.toBe('curl'); + }); + + it('handles mixed format (strings + typed objects)', async () => { + await seedPublic({ + ...mockPublicConfig, + packages: JSON.stringify(['git', { name: 'docker', type: 'cask' }, 'wget']), + snapshot: JSON.stringify({ packages: { taps: [], casks: [] } }) }); + + const json = (await (await callPublic()).json()) as { + packages: { name: string; desc: string }[]; + casks: { name: string; desc: string }[]; + }; + expect(json.packages.map((p) => p.name)).toContain('git'); + expect(json.packages.map((p) => p.name)).toContain('wget'); + expect(json.casks).toContainEqual(expect.objectContaining({ name: 'docker' })); + for (const pkg of [...json.packages, ...json.casks]) { + expect(pkg).toHaveProperty('name'); + expect(pkg).toHaveProperty('desc'); + } + }); + + it('handles empty packages', async () => { + await seedPublic({ ...mockPublicConfig, packages: '[]' }); + + const json = (await (await callPublic()).json()) as { + packages: unknown[]; + casks: unknown[]; + npm: unknown[]; + taps: unknown[]; + }; + expect(json.packages).toEqual([]); + expect(json.casks).toEqual([]); + expect(json.npm).toEqual([]); + expect(json.taps).toEqual([]); + }); +}); + +describe('error handling', () => { + it('returns 500 when platform env missing', async () => { + const response = await GET({ + request: new Request(baseUrl), + platform: { env: undefined }, + url: new URL(baseUrl), + route: { id: '/[username]/[slug]/config' }, + params: { username: 'testuser', slug: 'public-config' }, + locals: {}, + isDataRequest: false, + isSubRequest: false, + cookies: { get: () => undefined, set: () => {}, delete: () => {}, getAll: () => [], serialize: () => '' }, + getClientAddress: () => '', + fetch: globalThis.fetch + } as any); + + expect(response.status).toBe(500); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Platform env not available'); + }); + + it('returns 404 when user not found', async () => { + const response = await call(GET, opts({ params: { username: 'nonexistent', slug: 'config' } })); + + expect(response.status).toBe(404); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('User not found'); + }); + + it('returns 404 when config not found', async () => { + await seed(db, { users: [userRow()] }); + + const response = await call(GET, opts({ params: { username: 'testuser', slug: 'nonexistent' } })); + + expect(response.status).toBe(404); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Config not found'); }); }); diff --git a/src/routes/[username]/[slug]/install/server.test.ts b/src/routes/[username]/[slug]/install/server.test.ts index 5f397f5..dcce140 100644 --- a/src/routes/[username]/[slug]/install/server.test.ts +++ b/src/routes/[username]/[slug]/install/server.test.ts @@ -1,324 +1,186 @@ /** - * Tests for install script endpoint - * Critical: Private config access control via Bearer tokens + * Tests for install script endpoint. + * Runs inside Workers runtime with real D1 (via vitest-pool-workers). */ import { describe, it, expect, beforeEach } from 'vitest'; -import { GET as _GET } from './+server'; -const GET = _GET as (event: any) => Promise; -import { createMockDB } from '$lib/test/db-mock'; -import { - mockUser, - mockPublicConfig, - mockPrivateConfig, - mockApiToken, - mockExpiredApiToken, - createMockRequest, - createMockPlatform -} from '$lib/test/fixtures'; -import { createBearerToken } from '$lib/test/helpers'; - -describe('[username]/[slug]/install GET - Visibility Auth', () => { - const baseUrl = 'http://localhost:5173/testuser/my-config/install'; - - describe('Public configs', () => { - it('should return install script without auth', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockPublicConfig] - }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/install' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toContain('text/plain'); - - const text = await response.text(); - expect(text).toContain('#!/bin/bash'); - expect(text).toContain('openboot'); - }); +import { env } from 'cloudflare:test'; +import { GET } from './+server'; +import { resetDb, seed, strip } from '$lib/test/seed'; +import { call } from '$lib/test/call'; +import { mockUser, mockPublicConfig, mockPrivateConfig, mockApiToken, mockExpiredApiToken } from '$lib/test/fixtures'; + +const db = env.DB; +const baseUrl = 'http://localhost:5173/testuser/my-config/install'; +const userRow = () => strip(mockUser, 'provider', 'provider_id'); + +const opts = (overrides: Record = {}) => ({ + url: baseUrl, + route: { id: '/[username]/[slug]/install' }, + params: { username: 'testuser', slug: 'my-config' }, + ...overrides +}); + +beforeEach(async () => { + await resetDb(db); +}); + +describe('public configs', () => { + it('returns install script without auth', async () => { + await seed(db, { users: [userRow()], configs: [mockPublicConfig] }); + + const response = await call(GET, opts({ params: { username: 'testuser', slug: 'public-config' } })); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/plain'); + const text = await response.text(); + expect(text).toContain('#!/bin/bash'); + expect(text).toContain('openboot'); }); +}); - describe('Private configs - Auth required', () => { - it('should reject private config without auth header', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockPrivateConfig] - }); +describe('private configs — auth required', () => { + it('rejects private config without auth header', async () => { + await seed(db, { users: [userRow()], configs: [mockPrivateConfig] }); - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); + const response = await call(GET, opts({ params: { username: 'testuser', slug: 'private-config' } })); - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'private-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/install' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(403); - const text = await response.text(); - expect(text).toContain('Config is private'); - }); + expect(response.status).toBe(403); + const text = await response.text(); + expect(text).toContain('Config is private'); + }); - it('should reject private config with empty Bearer token', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockPrivateConfig] - }); + it('rejects private config with empty Bearer token', async () => { + await seed(db, { users: [userRow()], configs: [mockPrivateConfig] }); - const request = createMockRequest({ - url: baseUrl, + const response = await call( + GET, + opts({ + params: { username: 'testuser', slug: 'private-config' }, headers: { authorization: 'Bearer ' } - }); - const platform = createMockPlatform(db); + }) + ); - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'private-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/install' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(403); + expect(response.status).toBe(403); + }); + + it('rejects private config with invalid token', async () => { + await seed(db, { + users: [userRow()], + configs: [mockPrivateConfig], + api_tokens: [mockApiToken] }); - it('should reject private config with invalid token', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockPrivateConfig], - api_tokens: [mockApiToken] - }); - - const request = createMockRequest({ - url: baseUrl, - headers: { authorization: createBearerToken('obt_invalid_token_123') } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, + const response = await call( + GET, + opts({ params: { username: 'testuser', slug: 'private-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/install' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(403); + token: 'obt_invalid_token_123' + }) + ); + + expect(response.status).toBe(403); + }); + + it('rejects private config with expired token', async () => { + await seed(db, { + users: [userRow()], + configs: [mockPrivateConfig], + api_tokens: [mockExpiredApiToken] }); - it('should reject private config with expired token', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockPrivateConfig], - api_tokens: [mockExpiredApiToken] - }); - - const request = createMockRequest({ - url: baseUrl, - headers: { authorization: createBearerToken(mockExpiredApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, + const response = await call( + GET, + opts({ params: { username: 'testuser', slug: 'private-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/install' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(403); + token: mockExpiredApiToken.token + }) + ); + + expect(response.status).toBe(403); + }); + + it('rejects private config with token from different user', async () => { + const otherUser = { ...userRow(), id: 'user_other', username: 'otheruser' }; + const otherToken = { + ...mockApiToken, + id: 'tok_other', + user_id: 'user_other', + token: 'obt_other_token_xxxxxxxxxxxxxxxxxxxx' + }; + await seed(db, { + users: [userRow(), otherUser], + configs: [mockPrivateConfig], + api_tokens: [otherToken] }); - it('should reject private config with token from different user', async () => { - const otherUser = { ...mockUser, id: 'user_other', username: 'otheruser' }; - const otherToken = { ...mockApiToken, id: 'tok_other', user_id: 'user_other' }; - - const db = createMockDB({ - users: [mockUser, otherUser], - configs: [mockPrivateConfig], - api_tokens: [otherToken] - }); - - const request = createMockRequest({ - url: baseUrl, - headers: { authorization: createBearerToken(otherToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, + const response = await call( + GET, + opts({ params: { username: 'testuser', slug: 'private-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/install' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(403); + token: otherToken.token + }) + ); + + expect(response.status).toBe(403); + }); + + it('returns install script with valid owner token', async () => { + await seed(db, { + users: [userRow()], + configs: [mockPrivateConfig], + api_tokens: [mockApiToken] }); - it('should return install script with valid owner token', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockPrivateConfig], - api_tokens: [mockApiToken] - }); - - const request = createMockRequest({ - url: baseUrl, - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, + const response = await call( + GET, + opts({ params: { username: 'testuser', slug: 'private-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/install' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(200); - - const text = await response.text(); - expect(text).toContain('#!/bin/bash'); - expect(text).toContain('openboot'); + token: mockApiToken.token + }) + ); + + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain('#!/bin/bash'); + expect(text).toContain('openboot'); + }); + + it('handles Bearer token with mixed case', async () => { + await seed(db, { + users: [userRow()], + configs: [mockPrivateConfig], + api_tokens: [mockApiToken] }); - it('should handle Bearer token with mixed case', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockPrivateConfig], - api_tokens: [mockApiToken] - }); - - const request = createMockRequest({ - url: baseUrl, - headers: { authorization: `bearer ${mockApiToken.token}` } // lowercase - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, + const response = await call( + GET, + opts({ params: { username: 'testuser', slug: 'private-config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/install' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(200); - }); + headers: { authorization: `bearer ${mockApiToken.token}` } + }) + ); + + expect(response.status).toBe(200); }); +}); - describe('404 cases', () => { - it('should return 404 for non-existent user', async () => { - const db = createMockDB({ users: [], configs: [] }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'nonexistent', slug: 'config' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/install' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(404); - const text = await response.text(); - expect(text).toContain('User not found'); - }); +describe('404 cases', () => { + it('returns 404 for non-existent user', async () => { + const response = await call(GET, opts({ params: { username: 'nonexistent', slug: 'config' } })); - it('should return 404 for non-existent config', async () => { - const db = createMockDB({ users: [mockUser], configs: [] }); - - const request = createMockRequest({ url: baseUrl }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { username: 'testuser', slug: 'nonexistent' }, - url: new URL(baseUrl), - route: { id: '/[username]/[slug]/install' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: {} as any, - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(404); - const text = await response.text(); - expect(text).toContain('Config not found'); - }); + expect(response.status).toBe(404); + const text = await response.text(); + expect(text).toContain('User not found'); + }); + + it('returns 404 for non-existent config', async () => { + await seed(db, { users: [userRow()] }); + + const response = await call(GET, opts({ params: { username: 'testuser', slug: 'nonexistent' } })); + + expect(response.status).toBe(404); + const text = await response.text(); + expect(text).toContain('Config not found'); }); }); diff --git a/src/routes/api/auth/cli/approve/server.test.ts b/src/routes/api/auth/cli/approve/server.test.ts index 11a5121..f8aab27 100644 --- a/src/routes/api/auth/cli/approve/server.test.ts +++ b/src/routes/api/auth/cli/approve/server.test.ts @@ -1,359 +1,204 @@ -import { describe, it, expect } from 'vitest'; -import { POST as _POST } from './+server'; -const POST = _POST as (event: any) => Promise; -import { createMockDB } from '$lib/test/db-mock'; -import { - mockUser, - mockApiToken, - createMockRequest, - createMockPlatform, - createMockCookies -} from '$lib/test/fixtures'; -import { createBearerToken, getJSON } from '$lib/test/helpers'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import { POST } from './+server'; +import { resetDb, seed, strip } from '$lib/test/seed'; +import { call } from '$lib/test/call'; +import { mockUser, mockApiToken } from '$lib/test/fixtures'; + +const db = env.DB; +const baseUrl = 'http://localhost:5173/api/auth/cli/approve'; +const userRow = () => strip(mockUser, 'provider', 'provider_id'); + +beforeEach(async () => { + await resetDb(db); +}); describe('POST /api/auth/cli/approve', () => { - const baseUrl = 'http://localhost:5173/api/auth/cli/approve'; + const authed = (body: unknown) => call(POST, { url: baseUrl, method: 'POST', token: mockApiToken.token, body }); - it('should reject request without authentication', async () => { - const db = createMockDB({}); - const request = createMockRequest({ + it('rejects request without authentication', async () => { + const response = await call(POST, { url: baseUrl, method: 'POST', body: { code: 'TESTCODE' } }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/auth/cli/approve' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); expect(response.status).toBe(401); - const json = await getJSON(response); + const json = (await response.json()) as { error: string }; expect(json.error).toContain('Unauthorized'); }); - it('should reject request with missing code', async () => { - const db = createMockDB({ - users: [mockUser], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - body: {}, - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); + it('rejects request with missing code', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/auth/cli/approve' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authed({}); expect(response.status).toBe(400); - const json = await getJSON(response); + const json = (await response.json()) as { error: string }; expect(json.error).toContain('Code is required'); }); - it('should reject request with non-string code', async () => { - const db = createMockDB({ - users: [mockUser], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - body: { code: 123 }, - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); + it('rejects request with non-string code', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/auth/cli/approve' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authed({ code: 123 }); expect(response.status).toBe(400); - const json = await getJSON(response); + const json = (await response.json()) as { error: string }; expect(json.error).toContain('Code is required'); }); - it('should reject invalid or expired code', async () => { - const db = createMockDB({ - users: [mockUser], - api_tokens: [mockApiToken], - cli_auth_codes: [] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - body: { code: 'INVALIDCODE' }, - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); + it('rejects invalid (non-existent) code', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/auth/cli/approve' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authed({ code: 'INVALIDCODE' }); expect(response.status).toBe(400); - const json = await getJSON(response); + const json = (await response.json()) as { error: string }; expect(json.error).toContain('Invalid or expired code'); }); - it('should reject expired code', async () => { - const expiredCode = { - id: 'code123', - code: 'EXPIRED1', - user_id: null, - token_id: null, - status: 'pending', - expires_at: '2020-01-01 00:00:00' - }; - const db = createMockDB({ - users: [mockUser], + it('rejects expired code', async () => { + await seed(db, { + users: [userRow()], api_tokens: [mockApiToken], - cli_auth_codes: [expiredCode] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - body: { code: 'EXPIRED1' }, - headers: { authorization: createBearerToken(mockApiToken.token) } + cli_auth_codes: [ + { + id: 'code123', + code: 'EXPIRED1', + user_id: null, + token_id: null, + status: 'pending', + expires_at: '2020-01-01 00:00:00' + } + ] }); - const platform = createMockPlatform(db); - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/auth/cli/approve' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authed({ code: 'EXPIRED1' }); expect(response.status).toBe(400); - const json = await getJSON(response); + const json = (await response.json()) as { error: string }; expect(json.error).toContain('Invalid or expired code'); }); - - - it('should approve valid pending code', async () => { - const pendingCode = { - id: 'code123', - code: 'PENDING1', - user_id: null, - token_id: null, - status: 'pending', - expires_at: '2099-01-01 00:00:00' - }; - const db = createMockDB({ - users: [mockUser], + it('approves valid pending code', async () => { + await seed(db, { + users: [userRow()], api_tokens: [mockApiToken], - cli_auth_codes: [pendingCode] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - body: { code: 'PENDING1' }, - headers: { authorization: createBearerToken(mockApiToken.token) } + cli_auth_codes: [ + { + id: 'code123', + code: 'PENDING1', + user_id: null, + token_id: null, + status: 'pending', + expires_at: '2099-01-01 00:00:00' + } + ] }); - const platform = createMockPlatform(db); - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/auth/cli/approve' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authed({ code: 'PENDING1' }); expect(response.status).toBe(200); - const json = await getJSON(response); + const json = (await response.json()) as { success: boolean }; expect(json.success).toBe(true); }); - it('should create API token with 90 day expiration', async () => { - const pendingCode = { - id: 'code123', - code: 'PENDING1', - user_id: null, - token_id: null, - status: 'pending', - expires_at: '2099-01-01 00:00:00' - }; - const db = createMockDB({ - users: [mockUser], + it('creates API token with obt_ prefix', async () => { + await seed(db, { + users: [userRow()], api_tokens: [mockApiToken], - cli_auth_codes: [pendingCode] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - body: { code: 'PENDING1' }, - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/auth/cli/approve' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const tokens = db.data.api_tokens || []; - const newToken = tokens.find((t: any) => t.name === 'cli' && t.user_id === mockUser.id); - expect(newToken).toBeDefined(); - expect(newToken.token).toMatch(/^obt_[a-f0-9]{32}$/); + cli_auth_codes: [ + { + id: 'code123', + code: 'PENDING1', + user_id: null, + token_id: null, + status: 'pending', + expires_at: '2099-01-01 00:00:00' + } + ] + }); + + await authed({ code: 'PENDING1' }); + + const newToken = await db + .prepare(`SELECT token FROM api_tokens WHERE name = 'cli' AND user_id = ?`) + .bind(mockUser.id) + .first<{ token: string }>(); + expect(newToken).not.toBeNull(); + expect(newToken!.token).toMatch(/^obt_[a-f0-9]{32}$/); }); - it('should update code status to approved', async () => { - const pendingCode = { - id: 'code123', - code: 'PENDING1', - user_id: null, - token_id: null, - status: 'pending', - expires_at: '2099-01-01 00:00:00' - }; - const db = createMockDB({ - users: [mockUser], + it('updates code status to approved', async () => { + await seed(db, { + users: [userRow()], api_tokens: [mockApiToken], - cli_auth_codes: [pendingCode] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - body: { code: 'PENDING1' }, - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/auth/cli/approve' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - const updatedCode = db.data.cli_auth_codes?.find((c: any) => c.id === 'code123'); - expect(updatedCode?.status).toBe('approved'); - expect(updatedCode?.user_id).toBe(mockUser.id); - expect(updatedCode?.token_id).toBeDefined(); + cli_auth_codes: [ + { + id: 'code123', + code: 'PENDING1', + user_id: null, + token_id: null, + status: 'pending', + expires_at: '2099-01-01 00:00:00' + } + ] + }); + + await authed({ code: 'PENDING1' }); + + const updated = await db + .prepare('SELECT status, user_id, token_id FROM cli_auth_codes WHERE id = ?') + .bind('code123') + .first<{ status: string; user_id: string; token_id: string }>(); + expect(updated?.status).toBe('approved'); + expect(updated?.user_id).toBe(mockUser.id); + expect(updated?.token_id).toBeTruthy(); }); - it('should reject invalid JSON body', async () => { - const db = createMockDB({ - users: [mockUser], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ + it('rejects invalid JSON body', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); + + const response = await call(POST, { url: baseUrl, method: 'POST', - body: 'invalid-json', - invalidJSON: true, - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/auth/cli/approve' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch + token: mockApiToken.token, + headers: { 'content-type': 'application/json' }, + body: 'invalid-json' }); expect(response.status).toBe(400); - const json = await getJSON(response); + const json = (await response.json()) as { error: string }; expect(json.error).toContain('Invalid request body'); }); - it('should return 500 if platform env not available', async () => { - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - body: { code: 'TESTCODE' } - }); - const platform = { env: undefined } as any; - + it('returns 500 if platform env not available', async () => { const response = await POST({ - request, - platform, + request: new Request(baseUrl, { + method: 'POST', + body: JSON.stringify({ code: 'TESTCODE' }), + headers: { 'content-type': 'application/json' } + }), + platform: { env: undefined }, url: new URL(baseUrl), route: { id: '/api/auth/cli/approve' }, locals: {}, isDataRequest: false, isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', + cookies: { + get: () => undefined, + set: () => {}, + delete: () => {}, + getAll: () => [], + serialize: () => '' + }, + getClientAddress: () => '127.0.0.99', fetch: globalThis.fetch - }); + } as any); expect(response.status).toBe(500); - const json = await getJSON(response); + const json = (await response.json()) as { error: string }; expect(json.error).toContain('Platform env not available'); }); }); diff --git a/src/routes/api/auth/cli/poll/server.test.ts b/src/routes/api/auth/cli/poll/server.test.ts index da13488..fa1b06b 100644 --- a/src/routes/api/auth/cli/poll/server.test.ts +++ b/src/routes/api/auth/cli/poll/server.test.ts @@ -1,313 +1,206 @@ -import { describe, it, expect } from 'vitest'; -import { GET as _GET } from './+server'; -const GET = _GET as (event: any) => Promise; -import { createMockDB } from '$lib/test/db-mock'; -import { mockUser, createMockRequest, createMockPlatform, createMockCookies } from '$lib/test/fixtures'; -import { getJSON } from '$lib/test/helpers'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import { GET } from './+server'; +import { resetDb, seed, strip } from '$lib/test/seed'; +import { call } from '$lib/test/call'; +import { mockUser } from '$lib/test/fixtures'; + +const db = env.DB; +const baseUrl = 'http://localhost:5173/api/auth/cli/poll'; +const userRow = () => strip(mockUser, 'provider', 'provider_id'); + +const apiToken = { + id: 'token1', + user_id: mockUser.id, + token: 'obt_test_token_123', + name: 'cli', + expires_at: '2099-06-01 00:00:00', + created_at: '2025-01-01 00:00:00' +}; + +beforeEach(async () => { + await resetDb(db); +}); describe('GET /api/auth/cli/poll', () => { - const baseUrl = 'http://localhost:5173/api/auth/cli/poll'; - - it('should return 400 if code_id not provided', async () => { - const db = createMockDB({}); - const request = createMockRequest({ url: baseUrl, method: 'GET' }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/auth/cli/poll' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + it('returns 400 if code_id not provided', async () => { + const response = await call(GET, { url: baseUrl }); expect(response.status).toBe(400); - const json = await getJSON(response); + const json = (await response.json()) as { error: string }; expect(json.error).toContain('code_id is required'); }); - it('should return expired status if code_id not found', async () => { - const db = createMockDB({ cli_auth_codes: [] }); - const url = `${baseUrl}?code_id=nonexistent`; - const request = createMockRequest({ url, method: 'GET' }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - url: new URL(url), - route: { id: '/api/auth/cli/poll' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + it('returns expired status if code_id not found', async () => { + const response = await call(GET, { url: `${baseUrl}?code_id=nonexistent` }); expect(response.status).toBe(200); - const json = await getJSON(response); + const json = (await response.json()) as { status: string }; expect(json.status).toBe('expired'); }); - it('should return expired status if code expired', async () => { - const expiredCode = { - id: 'code123', - code: 'EXPIRED1', - user_id: null, - token_id: null, - status: 'pending', - expires_at: '2020-01-01 00:00:00' - }; - const db = createMockDB({ cli_auth_codes: [expiredCode] }); - const url = `${baseUrl}?code_id=code123`; - const request = createMockRequest({ url, method: 'GET' }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - url: new URL(url), - route: { id: '/api/auth/cli/poll' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch + it('returns expired status if code expired', async () => { + await seed(db, { + cli_auth_codes: [ + { + id: 'code123', + code: 'EXPIRED1', + user_id: null, + token_id: null, + status: 'pending', + expires_at: '2020-01-01 00:00:00' + } + ] }); + const response = await call(GET, { url: `${baseUrl}?code_id=code123` }); + expect(response.status).toBe(200); - const json = await getJSON(response); + const json = (await response.json()) as { status: string }; expect(json.status).toBe('expired'); }); - it('should return pending status for pending code', async () => { - const pendingCode = { - id: 'code123', - code: 'PENDING1', - user_id: null, - token_id: null, - status: 'pending', - expires_at: '2099-01-01 00:00:00' - }; - const db = createMockDB({ cli_auth_codes: [pendingCode] }); - const url = `${baseUrl}?code_id=code123`; - const request = createMockRequest({ url, method: 'GET' }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - url: new URL(url), - route: { id: '/api/auth/cli/poll' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch + it('returns pending status for pending code', async () => { + await seed(db, { + cli_auth_codes: [ + { + id: 'code123', + code: 'PENDING1', + user_id: null, + token_id: null, + status: 'pending', + expires_at: '2099-01-01 00:00:00' + } + ] }); + const response = await call(GET, { url: `${baseUrl}?code_id=code123` }); + expect(response.status).toBe(200); - const json = await getJSON(response); + const json = (await response.json()) as { status: string }; expect(json.status).toBe('pending'); }); - it('should return approved status with token for approved code', async () => { - const approvedCode = { - id: 'code123', - code: 'APPROVED', - user_id: mockUser.id, - token_id: 'token1', - status: 'approved', - expires_at: '2099-01-01 00:00:00' - }; - const apiToken = { - id: 'token1', - user_id: mockUser.id, - token: 'obt_test_token_123', - name: 'cli', - expires_at: '2099-06-01 00:00:00', - created_at: '2025-01-01 00:00:00' - }; - const db = createMockDB({ - cli_auth_codes: [approvedCode], + it('returns approved status with token for approved code', async () => { + await seed(db, { + users: [userRow()], api_tokens: [apiToken], - users: [mockUser] + cli_auth_codes: [ + { + id: 'code123', + code: 'APPROVED', + user_id: mockUser.id, + token_id: 'token1', + status: 'approved', + expires_at: '2099-01-01 00:00:00' + } + ] }); - const url = `${baseUrl}?code_id=code123`; - const request = createMockRequest({ url, method: 'GET' }); - const platform = createMockPlatform(db); - const response = await GET({ - request, - platform, - url: new URL(url), - route: { id: '/api/auth/cli/poll' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await call(GET, { url: `${baseUrl}?code_id=code123` }); expect(response.status).toBe(200); - const json = await getJSON(response); + const json = (await response.json()) as { + status: string; + token: string; + username: string; + expires_at: string; + }; expect(json.status).toBe('approved'); expect(json.token).toBe('obt_test_token_123'); expect(json.username).toBe('testuser'); expect(json.expires_at).toBe('2099-06-01T00:00:00Z'); }); - it('should mark approved code as used after first poll', async () => { - const approvedCode = { - id: 'code123', - code: 'APPROVED', - user_id: mockUser.id, - token_id: 'token1', - status: 'approved', - expires_at: '2099-01-01 00:00:00' - }; - const apiToken = { - id: 'token1', - user_id: mockUser.id, - token: 'obt_test_token_123', - name: 'cli', - expires_at: '2099-06-01 00:00:00', - created_at: '2025-01-01 00:00:00' - }; - const db = createMockDB({ - cli_auth_codes: [approvedCode], + it('marks approved code as used after first poll', async () => { + await seed(db, { + users: [userRow()], api_tokens: [apiToken], - users: [mockUser] - }); - const url = `${baseUrl}?code_id=code123`; - const request = createMockRequest({ url, method: 'GET' }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - url: new URL(url), - route: { id: '/api/auth/cli/poll' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch + cli_auth_codes: [ + { + id: 'code123', + code: 'APPROVED', + user_id: mockUser.id, + token_id: 'token1', + status: 'approved', + expires_at: '2099-01-01 00:00:00' + } + ] }); + const response = await call(GET, { url: `${baseUrl}?code_id=code123` }); expect(response.status).toBe(200); - const updatedCode = db.data.cli_auth_codes?.find((c: any) => c.id === 'code123'); - expect(updatedCode?.status).toBe('used'); + const updated = await db + .prepare('SELECT status FROM cli_auth_codes WHERE id = ?') + .bind('code123') + .first<{ status: string }>(); + expect(updated?.status).toBe('used'); }); - it('should still return token for used code (idempotent)', async () => { - const usedCode = { - id: 'code123', - code: 'USED1234', - user_id: mockUser.id, - token_id: 'token1', - status: 'used', - expires_at: '2099-01-01 00:00:00' - }; - const apiToken = { - id: 'token1', - user_id: mockUser.id, - token: 'obt_test_token_123', - name: 'cli', - expires_at: '2099-06-01 00:00:00', - created_at: '2025-01-01 00:00:00' - }; - const db = createMockDB({ - cli_auth_codes: [usedCode], + it('still returns token for used code (idempotent)', async () => { + await seed(db, { + users: [userRow()], api_tokens: [apiToken], - users: [mockUser] + cli_auth_codes: [ + { + id: 'code123', + code: 'USED1234', + user_id: mockUser.id, + token_id: 'token1', + status: 'used', + expires_at: '2099-01-01 00:00:00' + } + ] }); - const url = `${baseUrl}?code_id=code123`; - const request = createMockRequest({ url, method: 'GET' }); - const platform = createMockPlatform(db); - const response = await GET({ - request, - platform, - url: new URL(url), - route: { id: '/api/auth/cli/poll' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await call(GET, { url: `${baseUrl}?code_id=code123` }); expect(response.status).toBe(200); - const json = await getJSON(response); + const json = (await response.json()) as { status: string; token: string }; expect(json.status).toBe('approved'); expect(json.token).toBe('obt_test_token_123'); }); - it('should return expired status if approved but token missing', async () => { - const approvedCode = { - id: 'code123', - code: 'APPROVED', - user_id: 'user1', - token_id: null, - status: 'approved', - expires_at: '2099-01-01 00:00:00' - }; - const db = createMockDB({ cli_auth_codes: [approvedCode] }); - const url = `${baseUrl}?code_id=code123`; - const request = createMockRequest({ url, method: 'GET' }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - url: new URL(url), - route: { id: '/api/auth/cli/poll' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch + it('returns expired status if approved but token missing', async () => { + await seed(db, { + cli_auth_codes: [ + { + id: 'code123', + code: 'APPROVED', + user_id: 'user1', + token_id: null, + status: 'approved', + expires_at: '2099-01-01 00:00:00' + } + ] }); + const response = await call(GET, { url: `${baseUrl}?code_id=code123` }); + expect(response.status).toBe(200); - const json = await getJSON(response); + const json = (await response.json()) as { status: string }; expect(json.status).toBe('expired'); }); - it('should return 500 if platform env not available', async () => { + it('returns 500 if platform env not available', async () => { + // Bypass the call helper to inject a missing env. const url = `${baseUrl}?code_id=code123`; - const request = createMockRequest({ url, method: 'GET' }); - const platform = { env: undefined } as any; - const response = await GET({ - request, - platform, + request: new Request(url), + platform: { env: undefined }, url: new URL(url), route: { id: '/api/auth/cli/poll' }, locals: {}, isDataRequest: false, isSubRequest: false, - cookies: createMockCookies(), + cookies: { get: () => undefined, set: () => {}, delete: () => {}, getAll: () => [], serialize: () => '' }, getClientAddress: () => '', fetch: globalThis.fetch - }); + } as any); expect(response.status).toBe(500); - const json = await getJSON(response); + const json = (await response.json()) as { error: string }; expect(json.error).toContain('Platform env not available'); }); }); diff --git a/src/routes/api/auth/cli/start/server.test.ts b/src/routes/api/auth/cli/start/server.test.ts index 0c24166..1460183 100644 --- a/src/routes/api/auth/cli/start/server.test.ts +++ b/src/routes/api/auth/cli/start/server.test.ts @@ -1,131 +1,93 @@ -import { describe, it, expect } from 'vitest'; -import { POST as _POST } from './+server'; -const POST = _POST as (event: any) => Promise; -import { createMockDB } from '$lib/test/db-mock'; -import { createMockRequest, createMockPlatform, createMockCookies } from '$lib/test/fixtures'; -import { getJSON } from '$lib/test/helpers'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import { POST } from './+server'; +import { resetDb } from '$lib/test/seed'; +import { call } from '$lib/test/call'; -describe('POST /api/auth/cli/start', () => { - const baseUrl = 'http://localhost:5173/api/auth/cli/start'; - let testCounter = 0; +const db = env.DB; +const baseUrl = 'http://localhost:5173/api/auth/cli/start'; + +let testCounter = 0; +const nextIp = () => `127.0.0.${++testCounter}`; - it('should generate code and code_id', async () => { - const db = createMockDB({ cli_auth_codes: [] }); - const request = createMockRequest({ +beforeEach(async () => { + await resetDb(db); +}); + +describe('POST /api/auth/cli/start', () => { + it('generates code and code_id', async () => { + const response = await call(POST, { url: baseUrl, method: 'POST', body: {}, - clientIp: `127.0.0.${++testCounter}` - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/auth/cli/start' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch + clientAddress: nextIp() }); expect(response.status).toBe(200); - const json = await getJSON(response); + const json = (await response.json()) as { code: string; code_id: string }; expect(json.code_id).toBeDefined(); expect(json.code).toBeDefined(); expect(json.code).toHaveLength(8); expect(/^[A-Z0-9]{8}$/.test(json.code)).toBe(true); }); - it('should always server-generate codes regardless of body', async () => { - const db = createMockDB({ cli_auth_codes: [] }); - const request = createMockRequest({ + it('always server-generates codes regardless of body', async () => { + const response = await call(POST, { url: baseUrl, method: 'POST', body: { code: 'MYCUSTOM' }, - clientIp: `127.0.0.${++testCounter}` - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/auth/cli/start' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch + clientAddress: nextIp() }); expect(response.status).toBe(200); - const json = await getJSON(response); - // Code should be server-generated, not the client-supplied value + const json = (await response.json()) as { code: string; code_id: string }; expect(json.code).not.toBe('MYCUSTOM'); expect(json.code).toHaveLength(8); expect(json.code_id).toBeDefined(); }); - it('should store code with pending status and 10 minute expiration', async () => { - const db = createMockDB({ cli_auth_codes: [] }); - const request = createMockRequest({ + it('stores code with pending status', async () => { + const response = await call(POST, { url: baseUrl, method: 'POST', body: {}, - clientIp: `127.0.0.${++testCounter}` - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/auth/cli/start' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch + clientAddress: nextIp() }); expect(response.status).toBe(200); - const json = await getJSON(response); + const json = (await response.json()) as { code: string; code_id: string }; - const stored = db.data.cli_auth_codes?.find((c: any) => c.id === json.code_id); - expect(stored).toBeDefined(); - expect(stored.code).toBe(json.code); - expect(stored.status).toBe('pending'); + const stored = await db + .prepare('SELECT code, status FROM cli_auth_codes WHERE id = ?') + .bind(json.code_id) + .first<{ code: string; status: string }>(); + expect(stored).not.toBeNull(); + expect(stored!.code).toBe(json.code); + expect(stored!.status).toBe('pending'); }); - it('should return 500 if platform env not available', async () => { - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - body: {} - }); - const platform = { env: undefined } as any; - + it('returns 500 if platform env not available', async () => { const response = await POST({ - request, - platform, + request: new Request(baseUrl, { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }), + platform: { env: undefined }, url: new URL(baseUrl), route: { id: '/api/auth/cli/start' }, locals: {}, isDataRequest: false, isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', + cookies: { + get: () => undefined, + set: () => {}, + delete: () => {}, + getAll: () => [], + serialize: () => '' + }, + getClientAddress: () => '127.0.0.99', fetch: globalThis.fetch - }); + } as any); expect(response.status).toBe(500); - const json = await getJSON(response); + const json = (await response.json()) as { error: string }; expect(json.error).toContain('Platform env not available'); }); }); diff --git a/src/routes/api/configs/[slug]/revisions/server.test.ts b/src/routes/api/configs/[slug]/revisions/server.test.ts index 1add29a..e336664 100644 --- a/src/routes/api/configs/[slug]/revisions/server.test.ts +++ b/src/routes/api/configs/[slug]/revisions/server.test.ts @@ -5,93 +5,89 @@ * POST /api/configs/[slug]/revisions/[id]/restore */ -import { describe, it, expect } from 'vitest'; -import { GET as _LIST } from './+server'; -import { GET as _GET } from './[id]/+server'; -import { POST as _RESTORE } from './[id]/restore/+server'; -const LIST = _LIST as (event: any) => Promise; -const GET = _GET as (event: any) => Promise; -const RESTORE = _RESTORE as (event: any) => Promise; - -import { createMockDB } from '$lib/test/db-mock'; -import { - mockUser, - mockConfig, - mockApiToken, - mockRevision, - mockRevisionOlder, - createMockRequest, - createMockPlatform, - createMockCookies -} from '$lib/test/fixtures'; -import { createBearerToken, getJSON } from '$lib/test/helpers'; - -function makeEvent(db: any, params: Record, method = 'GET', body?: any) { - return { - request: createMockRequest({ - method, - url: `http://localhost:5173/api/configs/${params.slug || 'my-config'}/revisions`, - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: body ?? (method === 'POST' ? {} : null) - }), - platform: createMockPlatform(db), +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import { GET as LIST } from './+server'; +import { GET } from './[id]/+server'; +import { POST as RESTORE } from './[id]/restore/+server'; + +import { resetDb, seed, strip } from '$lib/test/seed'; +import { call } from '$lib/test/call'; +import { mockUser, mockConfig, mockApiToken, mockRevision, mockRevisionOlder } from '$lib/test/fixtures'; + +const db = env.DB; +const userRow = () => strip(mockUser, 'provider', 'provider_id'); + +// Mock-only fixture field; real schema doesn't have it (json_array_length computes). +function asRevisionRow(r: typeof mockRevision | typeof mockRevisionOlder) { + const { package_count: _omit, ...rest } = r; + return rest; +} + +function authedCall( + handler: (event: any) => Response | Promise, + params: Record, + method: 'GET' | 'POST' = 'GET', + body?: unknown +) { + return call(handler, { + url: `http://localhost:5173/api/configs/${params.slug}/revisions`, + method, params, - url: new URL('http://localhost:5173'), - route: { id: '' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '127.0.0.1', - fetch: globalThis.fetch - }; + token: mockApiToken.token, + body: method === 'POST' ? (body ?? {}) : body + }); } -function makeUnauthEvent(db: any, params: Record, method = 'GET') { - return { - ...makeEvent(db, params, method), - request: createMockRequest({ method, url: 'http://localhost:5173' }) - }; +function unauthCall( + handler: (event: any) => Response | Promise, + params: Record, + method: 'GET' | 'POST' = 'GET' +) { + return call(handler, { + url: `http://localhost:5173/api/configs/${params.slug}/revisions`, + method, + params + }); } -// ─── GET /api/configs/[slug]/revisions ────────────────────────────────────── +beforeEach(async () => { + await resetDb(db); +}); describe('GET /api/configs/[slug]/revisions', () => { it('rejects unauthenticated request', async () => { - const db = createMockDB({ users: [mockUser], configs: [mockConfig], api_tokens: [mockApiToken] }); - const res = await LIST(makeUnauthEvent(db, { slug: 'my-config' })); + await seed(db, { users: [userRow()], configs: [mockConfig], api_tokens: [mockApiToken] }); + const res = await unauthCall(LIST, { slug: 'my-config' }); expect(res.status).toBe(401); }); it('returns 404 for unknown config', async () => { - const db = createMockDB({ users: [mockUser], configs: [], api_tokens: [mockApiToken] }); - const res = await LIST(makeEvent(db, { slug: 'nonexistent' })); + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); + const res = await authedCall(LIST, { slug: 'nonexistent' }); expect(res.status).toBe(404); }); it('returns empty list when no revisions exist', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken], - config_revisions: [] - }); - const res = await LIST(makeEvent(db, { slug: 'my-config' })); + await seed(db, { users: [userRow()], configs: [mockConfig], api_tokens: [mockApiToken] }); + const res = await authedCall(LIST, { slug: 'my-config' }); expect(res.status).toBe(200); - const body = await getJSON(res); + const body = (await res.json()) as { revisions: unknown[] }; expect(body.revisions).toEqual([]); }); it('returns revisions list with id, message, created_at, package_count', async () => { - const db = createMockDB({ - users: [mockUser], + await seed(db, { + users: [userRow()], configs: [mockConfig], api_tokens: [mockApiToken], - config_revisions: [mockRevision, mockRevisionOlder] + config_revisions: [asRevisionRow(mockRevision), asRevisionRow(mockRevisionOlder)] }); - const res = await LIST(makeEvent(db, { slug: 'my-config' })); + const res = await authedCall(LIST, { slug: 'my-config' }); expect(res.status).toBe(200); - const body = await getJSON(res); + const body = (await res.json()) as { + revisions: { id: string; message: string; package_count: number; created_at: string }[]; + }; expect(body.revisions).toHaveLength(2); const first = body.revisions[0]; expect(first.id).toBe(mockRevision.id); @@ -101,101 +97,94 @@ describe('GET /api/configs/[slug]/revisions', () => { }); }); -// ─── GET /api/configs/[slug]/revisions/[id] ───────────────────────────────── - describe('GET /api/configs/[slug]/revisions/[id]', () => { it('rejects unauthenticated request', async () => { - const db = createMockDB({ users: [mockUser], configs: [mockConfig], api_tokens: [mockApiToken] }); - const res = await GET(makeUnauthEvent(db, { slug: 'my-config', id: mockRevision.id })); + await seed(db, { users: [userRow()], configs: [mockConfig], api_tokens: [mockApiToken] }); + const res = await unauthCall(GET, { slug: 'my-config', id: mockRevision.id }); expect(res.status).toBe(401); }); it('returns 404 for unknown config', async () => { - const db = createMockDB({ users: [mockUser], configs: [], api_tokens: [mockApiToken] }); - const res = await GET(makeEvent(db, { slug: 'nonexistent', id: mockRevision.id })); + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); + const res = await authedCall(GET, { slug: 'nonexistent', id: mockRevision.id }); expect(res.status).toBe(404); }); it('returns 404 for unknown revision', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken], - config_revisions: [] - }); - const res = await GET(makeEvent(db, { slug: 'my-config', id: 'rev_nonexistent' })); + await seed(db, { users: [userRow()], configs: [mockConfig], api_tokens: [mockApiToken] }); + const res = await authedCall(GET, { slug: 'my-config', id: 'rev_nonexistent' }); expect(res.status).toBe(404); }); it('returns revision with parsed packages array', async () => { - const db = createMockDB({ - users: [mockUser], + await seed(db, { + users: [userRow()], configs: [mockConfig], api_tokens: [mockApiToken], - config_revisions: [mockRevision] + config_revisions: [asRevisionRow(mockRevision)] }); - const res = await GET(makeEvent(db, { slug: 'my-config', id: mockRevision.id })); + const res = await authedCall(GET, { slug: 'my-config', id: mockRevision.id }); expect(res.status).toBe(200); - const body = await getJSON(res); + const body = (await res.json()) as { + id: string; + message: string; + packages: { name: string; type: string }[]; + }; expect(body.id).toBe(mockRevision.id); expect(body.message).toBe('before adding rust'); - // packages should be a parsed array, not a JSON string expect(Array.isArray(body.packages)).toBe(true); expect(body.packages).toHaveLength(2); expect(body.packages[0]).toEqual({ name: 'git', type: 'formula' }); }); it('returns empty packages array when packages JSON is invalid', async () => { - const badRevision = { ...mockRevision, id: 'rev_bad', packages: 'not-json' }; - const db = createMockDB({ - users: [mockUser], + const badRevision = { ...asRevisionRow(mockRevision), id: 'rev_bad', packages: 'not-json' }; + await seed(db, { + users: [userRow()], configs: [mockConfig], api_tokens: [mockApiToken], config_revisions: [badRevision] }); - const res = await GET(makeEvent(db, { slug: 'my-config', id: 'rev_bad' })); + const res = await authedCall(GET, { slug: 'my-config', id: 'rev_bad' }); expect(res.status).toBe(200); - const body = await getJSON(res); + const body = (await res.json()) as { packages: unknown[] }; expect(body.packages).toEqual([]); }); }); -// ─── POST /api/configs/[slug]/revisions/[id]/restore ──────────────────────── - describe('POST /api/configs/[slug]/revisions/[id]/restore', () => { it('rejects unauthenticated request', async () => { - const db = createMockDB({ users: [mockUser], configs: [mockConfig], api_tokens: [mockApiToken] }); - const res = await RESTORE(makeUnauthEvent(db, { slug: 'my-config', id: mockRevision.id }, 'POST')); + await seed(db, { users: [userRow()], configs: [mockConfig], api_tokens: [mockApiToken] }); + const res = await unauthCall(RESTORE, { slug: 'my-config', id: mockRevision.id }, 'POST'); expect(res.status).toBe(401); }); it('returns 404 for unknown config', async () => { - const db = createMockDB({ users: [mockUser], configs: [], api_tokens: [mockApiToken] }); - const res = await RESTORE(makeEvent(db, { slug: 'nonexistent', id: mockRevision.id }, 'POST')); + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); + const res = await authedCall(RESTORE, { slug: 'nonexistent', id: mockRevision.id }, 'POST'); expect(res.status).toBe(404); }); it('returns 404 for unknown revision', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken], - config_revisions: [] - }); - const res = await RESTORE(makeEvent(db, { slug: 'my-config', id: 'rev_nonexistent' }, 'POST')); + await seed(db, { users: [userRow()], configs: [mockConfig], api_tokens: [mockApiToken] }); + const res = await authedCall(RESTORE, { slug: 'my-config', id: 'rev_nonexistent' }, 'POST'); expect(res.status).toBe(404); }); it('returns 200 with restored packages on success', async () => { - const db = createMockDB({ - users: [mockUser], + await seed(db, { + users: [userRow()], configs: [mockConfig], api_tokens: [mockApiToken], - config_revisions: [mockRevision] + config_revisions: [asRevisionRow(mockRevision)] }); - const res = await RESTORE(makeEvent(db, { slug: 'my-config', id: mockRevision.id }, 'POST')); + const res = await authedCall(RESTORE, { slug: 'my-config', id: mockRevision.id }, 'POST'); expect(res.status).toBe(200); - const body = await getJSON(res); + const body = (await res.json()) as { + restored: boolean; + revision_id: string; + packages: { name: string; type: string }[]; + }; expect(body.restored).toBe(true); expect(body.revision_id).toBe(mockRevision.id); expect(Array.isArray(body.packages)).toBe(true); diff --git a/src/routes/api/configs/[slug]/server.test.ts b/src/routes/api/configs/[slug]/server.test.ts index 1131460..885a4b5 100644 --- a/src/routes/api/configs/[slug]/server.test.ts +++ b/src/routes/api/configs/[slug]/server.test.ts @@ -1,856 +1,372 @@ /** - * Tests for /api/configs/[slug] GET/PUT/DELETE endpoints - * Critical: Auth validation, visibility field validation, config updates + * Tests for /api/configs/[slug] GET/PUT/DELETE endpoints. + * Runs inside Workers runtime with real D1 (via vitest-pool-workers). */ -import { describe, it, expect } from 'vitest'; -import { GET as _GET, PUT as _PUT, DELETE as _DELETE } from './+server'; -const GET = _GET as (event: any) => Promise; -const PUT = _PUT as (event: any) => Promise; -const DELETE = _DELETE as (event: any) => Promise; -import { createMockDB } from '$lib/test/db-mock'; -import { - mockUser, - mockConfig, - mockPublicConfig, - mockPrivateConfig, - mockApiToken, - createMockRequest, - createMockPlatform, - createMockCookies -} from '$lib/test/fixtures'; -import { createBearerToken, getJSON } from '$lib/test/helpers'; - -describe('/api/configs/[slug] GET/PUT/DELETE', () => { - const baseUrl = 'http://localhost:5173/api/configs/my-config'; - - describe('GET - Retrieve single config', () => { - it('should reject request without auth', async () => { - const db = createMockDB({ users: [mockUser], configs: [mockConfig] }); - const request = createMockRequest({ url: baseUrl, method: 'GET' }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import { GET, PUT, DELETE } from './+server'; +import { resetDb, seed, strip } from '$lib/test/seed'; +import { call } from '$lib/test/call'; +import { mockUser, mockConfig, mockPublicConfig, mockApiToken } from '$lib/test/fixtures'; + +const db = env.DB; +const baseUrl = 'http://localhost:5173/api/configs/my-config'; +const userRow = () => strip(mockUser, 'provider', 'provider_id'); + +const callOpts = (overrides: Record = {}) => ({ + url: baseUrl, + route: { id: '/api/configs/[slug]' }, + params: { slug: 'my-config' }, + ...overrides +}); - expect(response.status).toBe(401); - const json = await getJSON(response); - expect(json.error).toContain('Unauthorized'); - }); +beforeEach(async () => { + await resetDb(db); +}); - it('should return 404 for non-existent config', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'GET', - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { slug: 'nonexistent' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); +describe('GET /api/configs/[slug]', () => { + it('rejects request without auth', async () => { + await seed(db, { users: [userRow()], configs: [mockConfig] }); - expect(response.status).toBe(404); - const json = await getJSON(response); - expect(json.error).toContain('Config not found'); - }); + const response = await call(GET, callOpts()); - it('should return config with all fields', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'GET', - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + expect(response.status).toBe(401); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Unauthorized'); + }); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.config.id).toBe('cfg_test123'); - expect(json.config.slug).toBe('my-config'); - expect(json.config.name).toBe('My Test Config'); - expect(json.config.visibility).toBe('unlisted'); - expect(json.install_url).toBeDefined(); - }); + it('returns 404 for non-existent config', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); - it('should return install_url with alias when present', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'GET', - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await call(GET, callOpts({ token: mockApiToken.token, params: { slug: 'nonexistent' } })); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.install_url).toContain('myconfig'); + expect(response.status).toBe(404); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Config not found'); + }); + + it('returns config with all fields', async () => { + await seed(db, { + users: [userRow()], + configs: [mockConfig], + api_tokens: [mockApiToken] }); - it('should parse packages correctly', async () => { - const configWithPackages = { - ...mockConfig, - packages: JSON.stringify([ - { name: 'git', type: 'formula' }, - { name: 'visual-studio-code', type: 'cask' } - ]) - }; - const db = createMockDB({ - users: [mockUser], - configs: [configWithPackages], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'GET', - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await call(GET, callOpts({ token: mockApiToken.token })); + + expect(response.status).toBe(200); + const json = (await response.json()) as { + config: { id: string; slug: string; name: string; visibility: string }; + install_url: string; + }; + expect(json.config.id).toBe('cfg_test123'); + expect(json.config.slug).toBe('my-config'); + expect(json.config.name).toBe('My Test Config'); + expect(json.config.visibility).toBe('unlisted'); + expect(json.install_url).toBeDefined(); + }); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.config.packages).toHaveLength(2); - expect(json.config.packages[0].name).toBe('git'); - expect(json.config.packages[1].type).toBe('cask'); + it('returns install_url with alias when present', async () => { + await seed(db, { + users: [userRow()], + configs: [mockConfig], + api_tokens: [mockApiToken] }); - }); - describe('PUT - Update config', () => { - it('should reject request without auth', async () => { - const db = createMockDB({ users: [mockUser], configs: [mockConfig] }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - body: { name: 'Updated' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await call(GET, callOpts({ token: mockApiToken.token })); + + expect(response.status).toBe(200); + const json = (await response.json()) as { install_url: string }; + expect(json.install_url).toContain('myconfig'); + }); - expect(response.status).toBe(401); - const json = await getJSON(response); - expect(json.error).toContain('Unauthorized'); + it('parses packages correctly', async () => { + const configWithPackages = { + ...mockConfig, + packages: JSON.stringify([ + { name: 'git', type: 'formula' }, + { name: 'visual-studio-code', type: 'cask' } + ]) + }; + await seed(db, { + users: [userRow()], + configs: [configWithPackages], + api_tokens: [mockApiToken] }); - it('should reject invalid JSON body', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = new Request(baseUrl, { - method: 'PUT', - headers: { - 'Authorization': createBearerToken(mockApiToken.token), - 'Content-Type': 'application/json' - }, - body: 'invalid json {' - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await call(GET, callOpts({ token: mockApiToken.token })); - expect(response.status).toBe(400); - const json = await getJSON(response); - expect(json.error).toContain('Invalid request body'); - }); + expect(response.status).toBe(200); + const json = (await response.json()) as { + config: { packages: { name: string; type: string }[] }; + }; + expect(json.config.packages).toHaveLength(2); + expect(json.config.packages[0].name).toBe('git'); + expect(json.config.packages[1].type).toBe('cask'); + }); +}); - it('should return 404 for non-existent config', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Updated' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'nonexistent' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); +describe('PUT /api/configs/[slug]', () => { + const authedPut = (body: unknown, overrides: Record = {}) => + call(PUT, callOpts({ method: 'PUT', token: mockApiToken.token, body, ...overrides })); - expect(response.status).toBe(404); - const json = await getJSON(response); - expect(json.error).toContain('Config not found'); - }); + it('rejects request without auth', async () => { + await seed(db, { users: [userRow()], configs: [mockConfig] }); - describe('Visibility validation on PUT', () => { - it('should update config with visibility=public', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { visibility: 'public' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await call(PUT, callOpts({ method: 'PUT', body: { name: 'Updated' } })); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.success).toBe(true); - }); + expect(response.status).toBe(401); + }); - it('should update config with visibility=private', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { visibility: 'private' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + it('rejects invalid JSON body', async () => { + await seed(db, { + users: [userRow()], + configs: [mockConfig], + api_tokens: [mockApiToken] + }); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.success).toBe(true); - }); + const response = await call( + PUT, + callOpts({ + method: 'PUT', + token: mockApiToken.token, + headers: { 'content-type': 'application/json' }, + body: 'invalid json {' + }) + ); - it('should update config with visibility=unlisted', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockPublicConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { visibility: 'unlisted' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'public-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + expect(response.status).toBe(400); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Invalid request body'); + }); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.success).toBe(true); - }); + it('returns 404 for non-existent config', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); - it('should reject invalid visibility value on PUT', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { visibility: 'invalid' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(400); - const json = await getJSON(response); - expect(json.error).toContain('Invalid visibility'); - expect(json.error).toContain('public, unlisted, or private'); - }); + const response = await authedPut({ name: 'Updated' }, { params: { slug: 'nonexistent' } }); - it('should reject visibility=secret on PUT', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { visibility: 'secret' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(400); - const json = await getJSON(response); - expect(json.error).toContain('Invalid visibility'); - }); + expect(response.status).toBe(404); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Config not found'); + }); - it('should allow undefined visibility (no change)', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Updated Name' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + describe('visibility validation', () => { + beforeEach(async () => { + await seed(db, { + users: [userRow()], + configs: [mockConfig], + api_tokens: [mockApiToken] + }); + }); + for (const visibility of ['public', 'private'] as const) { + it(`updates config with visibility=${visibility}`, async () => { + const response = await authedPut({ visibility }); expect(response.status).toBe(200); - const json = await getJSON(response); + const json = (await response.json()) as { success: boolean }; expect(json.success).toBe(true); }); + } + + it('rejects invalid visibility value', async () => { + const response = await authedPut({ visibility: 'invalid' }); + expect(response.status).toBe(400); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Invalid visibility'); + expect(json.error).toContain('public, unlisted, or private'); }); - it('should update config name and slug', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Renamed Config' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + it('rejects visibility=secret', async () => { + const response = await authedPut({ visibility: 'secret' }); + expect(response.status).toBe(400); + }); + it('allows undefined visibility (no change)', async () => { + const response = await authedPut({ name: 'Updated Name' }); expect(response.status).toBe(200); - const json = await getJSON(response); + const json = (await response.json()) as { success: boolean }; expect(json.success).toBe(true); - expect(json.slug).toBe('renamed-config'); }); + }); - it('should reject duplicate name (slug conflict)', async () => { - const config2 = { ...mockConfig, id: 'cfg_2', slug: 'other-config', name: 'Other Config' }; - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig, config2], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Other Config' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + it('updates visibility=unlisted on a public config', async () => { + await seed(db, { + users: [userRow()], + configs: [mockPublicConfig], + api_tokens: [mockApiToken] + }); + + const response = await authedPut({ visibility: 'unlisted' }, { params: { slug: 'public-config' } }); + + expect(response.status).toBe(200); + const json = (await response.json()) as { success: boolean }; + expect(json.success).toBe(true); + }); - expect(response.status).toBe(409); - const json = await getJSON(response); - expect(json.error).toContain('already exists'); + it('updates config name and slug', async () => { + await seed(db, { + users: [userRow()], + configs: [mockConfig], + api_tokens: [mockApiToken] }); - it('should update alias', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { alias: 'newalias' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authedPut({ name: 'Renamed Config' }); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.alias).toBe('newalias'); + expect(response.status).toBe(200); + const json = (await response.json()) as { success: boolean; slug: string }; + expect(json.success).toBe(true); + expect(json.slug).toBe('renamed-config'); + }); + + it('rejects duplicate name (slug conflict)', async () => { + const config2 = { + ...mockConfig, + id: 'cfg_2', + slug: 'other-config', + name: 'Other Config', + alias: null + }; + await seed(db, { + users: [userRow()], + configs: [mockConfig, config2], + api_tokens: [mockApiToken] }); - it('should clear alias when set to empty string', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { alias: '' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authedPut({ name: 'Other Config' }); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.alias).toBeNull(); + expect(response.status).toBe(409); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('already exists'); + }); + + it('updates alias', async () => { + await seed(db, { + users: [userRow()], + configs: [mockConfig], + api_tokens: [mockApiToken] }); - it('should reject duplicate alias', async () => { - const config2 = { ...mockConfig, id: 'cfg_2', slug: 'other-config', alias: 'taken' }; - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig, config2], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { alias: 'taken' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authedPut({ alias: 'newalias' }); + + expect(response.status).toBe(200); + const json = (await response.json()) as { alias: string }; + expect(json.alias).toBe('newalias'); + }); - expect(response.status).toBe(409); - const json = await getJSON(response); - expect(json.error).toContain('already taken'); + it('clears alias when set to empty string', async () => { + await seed(db, { + users: [userRow()], + configs: [mockConfig], + api_tokens: [mockApiToken] }); - it('should update description', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { description: 'New description' } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authedPut({ alias: '' }); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.success).toBe(true); + expect(response.status).toBe(200); + const json = (await response.json()) as { alias: string | null }; + expect(json.alias).toBeNull(); + }); + + it('rejects duplicate alias', async () => { + const config2 = { ...mockConfig, id: 'cfg_2', slug: 'other-config', alias: 'taken' }; + await seed(db, { + users: [userRow()], + configs: [mockConfig, config2], + api_tokens: [mockApiToken] }); - it('should update multiple fields at once', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'PUT', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { - name: 'Multi Update', - description: 'Updated description', - visibility: 'public', - alias: 'multiupdate' - } - }); - const platform = createMockPlatform(db); - - const response = await PUT({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authedPut({ alias: 'taken' }); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.success).toBe(true); - expect(json.slug).toBe('multi-update'); - expect(json.alias).toBe('multiupdate'); + expect(response.status).toBe(409); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('already taken'); + }); + + it('updates description', async () => { + await seed(db, { + users: [userRow()], + configs: [mockConfig], + api_tokens: [mockApiToken] }); + + const response = await authedPut({ description: 'New description' }); + + expect(response.status).toBe(200); + const json = (await response.json()) as { success: boolean }; + expect(json.success).toBe(true); }); - describe('DELETE - Remove config', () => { - it('should reject request without auth', async () => { - const db = createMockDB({ users: [mockUser], configs: [mockConfig] }); - const request = createMockRequest({ url: baseUrl, method: 'DELETE' }); - const platform = createMockPlatform(db); - - const response = await DELETE({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + it('updates multiple fields at once', async () => { + await seed(db, { + users: [userRow()], + configs: [mockConfig], + api_tokens: [mockApiToken] + }); - expect(response.status).toBe(401); - const json = await getJSON(response); - expect(json.error).toContain('Unauthorized'); + const response = await authedPut({ + name: 'Multi Update', + description: 'Updated description', + visibility: 'public', + alias: 'multiupdate' }); - it('should delete config successfully', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'DELETE', - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await DELETE({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + expect(response.status).toBe(200); + const json = (await response.json()) as { success: boolean; slug: string; alias: string }; + expect(json.success).toBe(true); + expect(json.slug).toBe('multi-update'); + expect(json.alias).toBe('multiupdate'); + }); +}); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.success).toBe(true); - }); +describe('DELETE /api/configs/[slug]', () => { + const authedDelete = (overrides: Record = {}) => + call(DELETE, callOpts({ method: 'DELETE', token: mockApiToken.token, ...overrides })); - it('should silently succeed for non-existent config', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'DELETE', - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await DELETE({ - request, - platform, - params: { slug: 'nonexistent' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + it('rejects request without auth', async () => { + await seed(db, { users: [userRow()], configs: [mockConfig] }); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.success).toBe(true); + const response = await call(DELETE, callOpts({ method: 'DELETE' })); + + expect(response.status).toBe(401); + }); + + it('deletes config successfully', async () => { + await seed(db, { + users: [userRow()], + configs: [mockConfig], + api_tokens: [mockApiToken] }); - it('should delete only the specified config', async () => { - const config2 = { ...mockConfig, id: 'cfg_2', slug: 'other-config' }; - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig, config2], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'DELETE', - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await DELETE({ - request, - platform, - params: { slug: 'my-config' }, - url: new URL(baseUrl), - route: { id: '/api/configs/[slug]' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authedDelete(); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.success).toBe(true); + expect(response.status).toBe(200); + const remaining = await db.prepare('SELECT id FROM configs WHERE id = ?').bind(mockConfig.id).first(); + expect(remaining).toBeNull(); + }); + + it('silently succeeds for non-existent config', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); + + const response = await authedDelete({ params: { slug: 'nonexistent' } }); + + expect(response.status).toBe(200); + const json = (await response.json()) as { success: boolean }; + expect(json.success).toBe(true); + }); + + it('deletes only the specified config', async () => { + const config2 = { ...mockConfig, id: 'cfg_2', slug: 'other-config', alias: null }; + await seed(db, { + users: [userRow()], + configs: [mockConfig, config2], + api_tokens: [mockApiToken] }); + + const response = await authedDelete(); + + expect(response.status).toBe(200); + const survivor = await db.prepare('SELECT id FROM configs WHERE id = ?').bind('cfg_2').first(); + expect(survivor).not.toBeNull(); }); }); diff --git a/src/routes/api/configs/server.test.ts b/src/routes/api/configs/server.test.ts index 1c2bdf9..2c37356 100644 --- a/src/routes/api/configs/server.test.ts +++ b/src/routes/api/configs/server.test.ts @@ -1,728 +1,272 @@ /** - * Tests for /api/configs GET/POST endpoints - * Critical: Auth validation, visibility field validation, config creation + * Tests for /api/configs GET/POST endpoints. + * Runs inside Workers runtime with real D1 (via vitest-pool-workers). */ import { describe, it, expect, beforeEach } from 'vitest'; -import { GET as _GET, POST as _POST } from './+server'; -const GET = _GET as (event: any) => Promise; -const POST = _POST as (event: any) => Promise; -import { createMockDB } from '$lib/test/db-mock'; -import { - mockUser, - mockConfig, - mockPublicConfig, - mockPrivateConfig, - mockApiToken, - createMockRequest, - createMockPlatform, - createMockCookies -} from '$lib/test/fixtures'; -import { createBearerToken, getJSON } from '$lib/test/helpers'; - -describe('/api/configs GET/POST', () => { - const baseUrl = 'http://localhost:5173/api/configs'; - - describe('GET - List configs', () => { - it('should reject request without auth', async () => { - const db = createMockDB({ users: [mockUser], configs: [mockConfig] }); - const request = createMockRequest({ url: baseUrl, method: 'GET' }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); +import { env } from 'cloudflare:test'; +import { GET, POST } from './+server'; +import { resetDb, seed, strip } from '$lib/test/seed'; +import { call } from '$lib/test/call'; +import { mockUser, mockConfig, mockPublicConfig, mockPrivateConfig, mockApiToken } from '$lib/test/fixtures'; - expect(response.status).toBe(401); - const json = await getJSON(response); - expect(json.error).toContain('Unauthorized'); - }); +const db = env.DB; +const baseUrl = 'http://localhost:5173/api/configs'; - it('should return empty list for user with no configs', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'GET', - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); +// users table has no provider/provider_id columns — that's auth-app metadata. +const userRow = () => strip(mockUser, 'provider', 'provider_id'); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.configs).toEqual([]); - expect(json.username).toBe('testuser'); - }); +beforeEach(async () => { + await resetDb(db); +}); - it('should return all user configs with visibility field', async () => { - const configs = [mockPublicConfig, mockPrivateConfig, mockConfig]; - const db = createMockDB({ - users: [mockUser], - configs, - api_tokens: [mockApiToken] - }); +describe('GET /api/configs', () => { + it('rejects request without auth', async () => { + await seed(db, { users: [userRow()], configs: [mockConfig] }); - const request = createMockRequest({ - url: baseUrl, - method: 'GET', - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await call(GET, { url: baseUrl }); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.configs).toHaveLength(3); - expect(json.configs[0].visibility).toBe('public'); - expect(json.configs[1].visibility).toBe('private'); - expect(json.configs[2].visibility).toBe('unlisted'); - }); + expect(response.status).toBe(401); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Unauthorized'); + }); - it('should parse snapshot JSON in response', async () => { - const configWithSnapshot = { - ...mockConfig, - snapshot: JSON.stringify({ packages: { formulas: ['git'] } }) - }; - const db = createMockDB({ - users: [mockUser], - configs: [configWithSnapshot], - api_tokens: [mockApiToken] - }); + it('returns empty list for user with no configs', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); - const request = createMockRequest({ - url: baseUrl, - method: 'GET', - headers: { authorization: createBearerToken(mockApiToken.token) } - }); - const platform = createMockPlatform(db); - - const response = await GET({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await call(GET, { url: baseUrl, token: mockApiToken.token }); - expect(response.status).toBe(200); - const json = await getJSON(response); - expect(json.configs[0].snapshot).toEqual({ packages: { formulas: ['git'] } }); - }); + expect(response.status).toBe(200); + const json = (await response.json()) as { configs: unknown[]; username: string }; + expect(json.configs).toEqual([]); + expect(json.username).toBe('testuser'); }); - describe('POST - Create config', () => { - it('should reject request without auth', async () => { - const db = createMockDB({ users: [mockUser], configs: [] }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - body: { name: 'New Config' } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + it('returns all user configs with visibility field', async () => { + await seed(db, { + users: [userRow()], + configs: [mockPublicConfig, mockPrivateConfig, mockConfig], + api_tokens: [mockApiToken] + }); + + const response = await call(GET, { url: baseUrl, token: mockApiToken.token }); - expect(response.status).toBe(401); - const json = await getJSON(response); - expect(json.error).toContain('Unauthorized'); + expect(response.status).toBe(200); + const json = (await response.json()) as { configs: { visibility: string }[] }; + expect(json.configs).toHaveLength(3); + const visibilities = new Set(json.configs.map((c) => c.visibility)); + expect(visibilities).toEqual(new Set(['public', 'private', 'unlisted'])); + }); + + it('parses snapshot JSON in response', async () => { + const configWithSnapshot = { + ...mockConfig, + snapshot: JSON.stringify({ packages: { formulas: ['git'] } }) + }; + await seed(db, { + users: [userRow()], + configs: [configWithSnapshot], + api_tokens: [mockApiToken] }); - it('should reject invalid JSON body', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = new Request(baseUrl, { - method: 'POST', - headers: { - 'Authorization': createBearerToken(mockApiToken.token), - 'Content-Type': 'application/json' - }, - body: 'invalid json {' - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await call(GET, { url: baseUrl, token: mockApiToken.token }); - expect(response.status).toBe(400); - const json = await getJSON(response); - expect(json.error).toContain('Invalid request body'); + expect(response.status).toBe(200); + const json = (await response.json()) as { configs: { snapshot: unknown }[] }; + expect(json.configs[0].snapshot).toEqual({ packages: { formulas: ['git'] } }); + }); +}); + +describe('POST /api/configs', () => { + const authed = (body: unknown) => call(POST, { url: baseUrl, method: 'POST', token: mockApiToken.token, body }); + + it('rejects request without auth', async () => { + await seed(db, { users: [userRow()] }); + + const response = await call(POST, { + url: baseUrl, + method: 'POST', + body: { name: 'New Config' } }); - it('should reject POST without name field', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { description: 'No name provided' } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + expect(response.status).toBe(401); + }); - expect(response.status).toBe(400); - const json = await getJSON(response); - expect(json.error).toContain('Name is required'); + it('rejects invalid JSON body', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); + + const response = await call(POST, { + url: baseUrl, + method: 'POST', + token: mockApiToken.token, + headers: { 'content-type': 'application/json' }, + body: 'invalid json {' }); - describe('Visibility validation', () => { - it('should create config with visibility=public', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Public Config', visibility: 'public' } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + expect(response.status).toBe(400); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Invalid request body'); + }); - expect(response.status).toBe(201); - const json = await getJSON(response); - expect(json.id).toBeDefined(); - expect(json.slug).toBe('public-config'); - }); + it('rejects POST without name field', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); - it('should create config with visibility=unlisted', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Unlisted Config', visibility: 'unlisted' } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authed({ description: 'No name provided' }); - expect(response.status).toBe(201); - const json = await getJSON(response); - expect(json.id).toBeDefined(); - }); + expect(response.status).toBe(400); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Name is required'); + }); - it('should create config with visibility=private', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Private Config', visibility: 'private' } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + describe('visibility validation', () => { + beforeEach(async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); + }); + for (const visibility of ['public', 'unlisted', 'private'] as const) { + it(`creates config with visibility=${visibility}`, async () => { + const response = await authed({ name: `${visibility} Config`, visibility }); expect(response.status).toBe(201); - const json = await getJSON(response); + const json = (await response.json()) as { id: string }; expect(json.id).toBeDefined(); }); + } - it('should reject invalid visibility value', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Bad Config', visibility: 'invalid' } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(400); - const json = await getJSON(response); - expect(json.error).toContain('Invalid visibility'); - expect(json.error).toContain('public, unlisted, or private'); - }); + it('rejects invalid visibility value', async () => { + const response = await authed({ name: 'Bad Config', visibility: 'invalid' }); + expect(response.status).toBe(400); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Invalid visibility'); + expect(json.error).toContain('public, unlisted, or private'); + }); - it('should reject visibility=secret', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Secret Config', visibility: 'secret' } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(400); - const json = await getJSON(response); - expect(json.error).toContain('Invalid visibility'); - }); + it('rejects visibility=secret', async () => { + const response = await authed({ name: 'Secret Config', visibility: 'secret' }); + expect(response.status).toBe(400); + }); - it('should default to unlisted when visibility not provided', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Default Visibility Config' } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + it('defaults to unlisted when visibility not provided', async () => { + const response = await authed({ name: 'Default Visibility Config' }); + expect(response.status).toBe(201); + }); + }); - expect(response.status).toBe(201); - const json = await getJSON(response); - expect(json.id).toBeDefined(); - }); + it('rejects duplicate config name (same slug)', async () => { + await seed(db, { + users: [userRow()], + configs: [mockConfig], + api_tokens: [mockApiToken] }); - it('should reject duplicate config name (same slug)', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [mockConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'My Config' } // Same as mockConfig - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authed({ name: 'My Config' }); - expect(response.status).toBe(409); - const json = await getJSON(response); - expect(json.error).toContain('already exists'); + expect(response.status).toBe(409); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('already exists'); + }); + + it('creates config with all optional fields', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); + + const response = await authed({ + name: 'Full Config', + description: 'Complete config with all fields', + base_preset: 'full', + packages: [{ name: 'git', type: 'formula' }], + custom_script: 'echo "test"', + visibility: 'public', + alias: 'fullcfg', + dotfiles_repo: 'https://github.com/user/dotfiles' }); - it('should create config with all optional fields', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { - name: 'Full Config', - description: 'Complete config with all fields', - base_preset: 'full', - packages: [{ name: 'git', type: 'formula' }], - custom_script: 'echo "test"', - visibility: 'public', - alias: 'fullcfg', - dotfiles_repo: 'https://github.com/user/dotfiles' - } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + expect(response.status).toBe(201); + const json = (await response.json()) as { id: string; slug: string; alias: string; install_url: string }; + expect(json.id).toBeDefined(); + expect(json.slug).toBe('full-config'); + expect(json.alias).toBe('fullcfg'); + expect(json.install_url).toContain('fullcfg'); + }); - expect(response.status).toBe(201); - const json = await getJSON(response); - expect(json.id).toBeDefined(); - expect(json.slug).toBe('full-config'); - expect(json.alias).toBe('fullcfg'); - expect(json.install_url).toContain('fullcfg'); - }); + it('returns install_url with alias when provided', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); - it('should return install_url with alias when provided', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Aliased Config', alias: 'myalias' } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authed({ name: 'Aliased Config', alias: 'myalias' }); - expect(response.status).toBe(201); - const json = await getJSON(response); - expect(json.alias).toBe('myalias'); - expect(json.install_url).toContain('myalias'); - }); + expect(response.status).toBe(201); + const json = (await response.json()) as { alias: string; install_url: string }; + expect(json.alias).toBe('myalias'); + expect(json.install_url).toContain('myalias'); + }); - it('should return install_url with username/slug when no alias', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'No Alias Config' } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + it('returns install_url with username/slug when no alias', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); - expect(response.status).toBe(201); - const json = await getJSON(response); - expect(json.alias).toBeNull(); - expect(json.install_url).toContain('testuser'); - expect(json.install_url).toContain('no-alias-config'); - }); + const response = await authed({ name: 'No Alias Config' }); - it('should reject alias that is too short', async () => { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Config', alias: 'a' } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + expect(response.status).toBe(201); + const json = (await response.json()) as { alias: string | null; install_url: string }; + expect(json.alias).toBeNull(); + expect(json.install_url).toContain('testuser'); + expect(json.install_url).toContain('no-alias-config'); + }); + + it('rejects alias that is too short', async () => { + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); + + const response = await authed({ name: 'Config', alias: 'a' }); + + expect(response.status).toBe(400); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Alias must be at least 2 characters'); + }); + + it('rejects reserved alias names', async () => { + const reserved = ['api', 'install', 'dashboard', 'login', 'docs', 'cli-auth', 'explore']; + for (const alias of reserved) { + await resetDb(db); + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); + + const response = await authed({ name: `Config ${alias}`, alias }); expect(response.status).toBe(400); - const json = await getJSON(response); - expect(json.error).toContain('Alias must be at least 2 characters'); - }); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('reserved'); + } + }); - it('should reject reserved alias names', async () => { - const reservedAliases = ['api', 'install', 'dashboard', 'login', 'docs', 'cli-auth', 'explore']; - - for (const alias of reservedAliases) { - const db = createMockDB({ - users: [mockUser], - configs: [], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: `Config ${alias}`, alias } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); - - expect(response.status).toBe(400); - const json = await getJSON(response); - expect(json.error).toContain('reserved'); - } + it('rejects duplicate alias', async () => { + const existingConfig = { ...mockConfig, alias: 'taken' }; + await seed(db, { + users: [userRow()], + configs: [existingConfig], + api_tokens: [mockApiToken] }); - it('should reject duplicate alias', async () => { - const existingConfig = { ...mockConfig, alias: 'taken' }; - const db = createMockDB({ - users: [mockUser], - configs: [existingConfig], - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Another Config', alias: 'taken' } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authed({ name: 'Another Config', alias: 'taken' }); + + expect(response.status).toBe(409); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('already taken'); + }); - expect(response.status).toBe(409); - const json = await getJSON(response); - expect(json.error).toContain('already taken'); + it('enforces max 20 configs per user', async () => { + const configs = Array.from({ length: 20 }, (_, i) => ({ + ...mockConfig, + id: `cfg_${i}`, + slug: `config-${i}`, + name: `Config ${i}`, + alias: null + })); + await seed(db, { + users: [userRow()], + configs, + api_tokens: [mockApiToken] }); - it('should enforce max 20 configs per user', async () => { - const configs = Array.from({ length: 20 }, (_, i) => ({ - ...mockConfig, - id: `cfg_${i}`, - slug: `config-${i}`, - name: `Config ${i}` - })); - - const db = createMockDB({ - users: [mockUser], - configs, - api_tokens: [mockApiToken] - }); - const request = createMockRequest({ - url: baseUrl, - method: 'POST', - headers: { authorization: createBearerToken(mockApiToken.token) }, - body: { name: 'Config 21' } - }); - const platform = createMockPlatform(db); - - const response = await POST({ - request, - platform, - url: new URL(baseUrl), - route: { id: '/api/configs' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch - }); + const response = await authed({ name: 'Config 21' }); - expect(response.status).toBe(400); - const json = await getJSON(response); - expect(json.error).toContain('Maximum 20 configs'); - }); + expect(response.status).toBe(400); + const json = (await response.json()) as { error: string }; + expect(json.error).toContain('Maximum 20 configs'); }); }); diff --git a/src/routes/api/health/server.test.ts b/src/routes/api/health/server.test.ts index f14440e..28e4f08 100644 --- a/src/routes/api/health/server.test.ts +++ b/src/routes/api/health/server.test.ts @@ -1,47 +1,38 @@ import { describe, it, expect } from 'vitest'; +import { env } from 'cloudflare:test'; import { GET } from './+server'; -import { createMockDB } from '$lib/test/db-mock'; -import { createMockPlatform } from '$lib/test/fixtures'; describe('GET /api/health', () => { - it('returns health check response', async () => { - const db = createMockDB(); - const platform = createMockPlatform(db); - - const response = await GET({ platform } as any); - const data = await response.json(); - - expect(data.status).toBeDefined(); - expect(data.checks).toBeDefined(); + it('returns healthy when DB responds', async () => { + const response = await GET({ platform: { env } } as any); + const data = (await response.json()) as Record; + + expect(response.status).toBe(200); + expect(data.status).toBe('healthy'); expect(data.checks.api).toBe('ok'); - expect(data.checks.database).toBeDefined(); + expect(data.checks.database).toBe('ok'); expect(data.version).toBe('0.1.0'); expect(data.checks.timestamp).toBeTruthy(); }); - it('returns degraded status when database fails', async () => { - const db = { + it('returns degraded when DB throws', async () => { + const brokenDb = { prepare: () => ({ - first: async () => { throw new Error('DB error'); } + first: async () => { + throw new Error('DB error'); + } }) - } as any; - - const platform = createMockPlatform(db); - - const response = await GET({ platform } as any); - const data = await response.json(); - + }; + const response = await GET({ platform: { env: { ...env, DB: brokenDb } } } as any); + const data = (await response.json()) as Record; + expect(response.status).toBe(503); expect(data.status).toBe('degraded'); expect(data.checks.database).toBe('error'); }); it('sets no-cache headers', async () => { - const db = createMockDB(); - const platform = createMockPlatform(db); - - const response = await GET({ platform } as any); - + const response = await GET({ platform: { env } } as any); expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate'); }); }); diff --git a/src/smoke-tests/cli-auth-flow.test.ts b/src/smoke-tests/cli-auth-flow.test.ts index 29c7acb..b3f1074 100644 --- a/src/smoke-tests/cli-auth-flow.test.ts +++ b/src/smoke-tests/cli-auth-flow.test.ts @@ -1,224 +1,170 @@ /** - * Smoke Test: CLI Device Auth full flow - * - * Verifies: start → approve → poll → use token to access private config - * This catches regressions in the 3-step CLI authentication handshake. + * Smoke Test: CLI Device Auth full flow. + * start → approve → poll → use token to access private config, end-to-end on real D1. */ -import { describe, it, expect } from 'vitest'; -import { createMockDB } from '$lib/test/db-mock'; -import { - mockUser, - mockApiToken, - createMockRequest, - createMockPlatform, - createMockCookies -} from '$lib/test/fixtures'; -import { createBearerToken, getJSON } from '$lib/test/helpers'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import { resetDb, seed, strip } from '$lib/test/seed'; +import { call } from '$lib/test/call'; +import { mockUser, mockApiToken } from '$lib/test/fixtures'; import { POST as CliStart } from '../routes/api/auth/cli/start/+server'; import { POST as CliApprove } from '../routes/api/auth/cli/approve/+server'; import { GET as CliPoll } from '../routes/api/auth/cli/poll/+server'; import { GET as GetConfigJSON } from '../routes/[username]/[slug]/config/+server'; -function handler(db: any, overrides: Record = {}) { - return { - platform: createMockPlatform(db), - url: new URL('http://localhost:5173/api/auth/cli/start'), - route: { id: '' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '127.0.0.1', - fetch: globalThis.fetch, - ...overrides - }; -} - -describe('Smoke Test: CLI Auth Full Flow', () => { - it('start → approve → poll → access private config', async () => { - // Pre-seed auth code to avoid mock DB limitations with datetime() in INSERT/UPDATE. - // The mock DB can't parse datetime('now', '+10 minutes') (comma splits the fn) - // or compare space-delimited dates vs ISO format. - const futureExpiry = new Date(Date.now() + 10 * 60 * 1000).toISOString(); - const preseededCode = { - id: 'code_test1', - code: 'TESTAB12', - user_id: null as string | null, - token_id: null as string | null, - status: 'pending', - expires_at: futureExpiry - }; +const db = env.DB; +const userRow = () => strip(mockUser, 'provider', 'provider_id'); + +beforeEach(async () => { + await resetDb(db); +}); - const db = createMockDB({ - users: [mockUser], +describe('Smoke — CLI Auth full flow', () => { + it('start → approve → poll → access private config', async () => { + await seed(db, { + users: [userRow()], api_tokens: [mockApiToken], - configs: [{ - id: 'cfg_private1', - user_id: mockUser.id, - slug: 'secret-setup', - name: 'Secret Setup', - base_preset: 'developer', - packages: JSON.stringify([{ name: 'git', type: 'formula', desc: 'VCS' }]), - visibility: 'private', - custom_script: '', - dotfiles_repo: '', - snapshot: null, - alias: null, - install_count: 0, - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z' - }], - cli_auth_codes: [preseededCode] + configs: [ + { + id: 'cfg_private1', + user_id: mockUser.id, + slug: 'secret-setup', + name: 'Secret Setup', + base_preset: 'developer', + packages: JSON.stringify([{ name: 'git', type: 'formula', desc: 'VCS' }]), + visibility: 'private', + custom_script: '', + dotfiles_repo: '', + snapshot: null, + alias: null, + install_count: 0, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + } + ] }); - // === 1. Verify: private config is blocked without auth === - const blockedRes = await GetConfigJSON({ - ...handler(db, { params: { username: 'testuser', slug: 'secret-setup' } }), - request: createMockRequest({ url: '/testuser/secret-setup/config' }) - } as any); - + // 1. Verify private config is blocked without auth. + const blockedRes = await call(GetConfigJSON, { + url: 'http://localhost:5173/testuser/secret-setup/config', + params: { username: 'testuser', slug: 'secret-setup' } + }); expect(blockedRes.status).toBe(403); - // === 2. CLI starts auth flow (verify endpoint works) === - const startRes = await CliStart({ - ...handler(db), - request: createMockRequest({ - method: 'POST', - url: '/api/auth/cli/start', - body: {}, - clientIp: '127.0.0.1' - }) - } as any); - + // 2. CLI starts auth flow. + const startRes = await call(CliStart, { + url: 'http://localhost:5173/api/auth/cli/start', + method: 'POST', + body: {}, + clientAddress: '127.0.0.42' + }); expect(startRes.status).toBe(200); - const startData = await getJSON(startRes); + const startData = (await startRes.json()) as { code_id: string; code: string }; expect(startData.code_id).toBeDefined(); expect(startData.code).toHaveLength(8); - // === 3. Poll pre-seeded code — should be pending === - const pendingRes = await CliPoll({ - ...handler(db, { url: new URL(`http://localhost:5173/api/auth/cli/poll?code_id=${preseededCode.id}`) }), - request: createMockRequest({ url: `/api/auth/cli/poll?code_id=${preseededCode.id}` }) - } as any); - + // 3. Poll — pending. + const pendingRes = await call(CliPoll, { + url: `http://localhost:5173/api/auth/cli/poll?code_id=${startData.code_id}` + }); expect(pendingRes.status).toBe(200); - const pendingData = await getJSON(pendingRes); + const pendingData = (await pendingRes.json()) as { status: string }; expect(pendingData.status).toBe('pending'); - // === 4. Simulate approve by directly updating mock data === - // The approve endpoint uses UPDATE with datetime conditions that the mock DB - // can't handle. We simulate what the endpoint does: create token + update code. - const newTokenValue = 'obt_' + crypto.randomUUID().replace(/-/g, ''); - const newTokenId = crypto.randomUUID(); - db.data.api_tokens.push({ - id: newTokenId, - user_id: mockUser.id, - token: newTokenValue, - name: 'cli', - expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), - created_at: new Date().toISOString() + // 4. Approve via the real endpoint (browser-side approval, authed as user). + const approveRes = await call(CliApprove, { + url: 'http://localhost:5173/api/auth/cli/approve', + method: 'POST', + token: mockApiToken.token, + body: { code: startData.code } }); - preseededCode.status = 'approved'; - preseededCode.user_id = mockUser.id; - preseededCode.token_id = newTokenId; - - // === 5. Poll again — should be approved with token === - const approvedRes = await CliPoll({ - ...handler(db, { url: new URL(`http://localhost:5173/api/auth/cli/poll?code_id=${preseededCode.id}`) }), - request: createMockRequest({ url: `/api/auth/cli/poll?code_id=${preseededCode.id}` }) - } as any); + expect(approveRes.status).toBe(200); + // 5. Poll again — approved, with token. + const approvedRes = await call(CliPoll, { + url: `http://localhost:5173/api/auth/cli/poll?code_id=${startData.code_id}` + }); expect(approvedRes.status).toBe(200); - const approvedData = await getJSON(approvedRes); + const approvedData = (await approvedRes.json()) as { + status: string; + token: string; + username: string; + }; expect(approvedData.status).toBe('approved'); - expect(approvedData.token).toBe(newTokenValue); expect(approvedData.token).toMatch(/^obt_/); expect(approvedData.username).toBe('testuser'); - // === 6. Use new CLI token to access private config === - const privateRes = await GetConfigJSON({ - ...handler(db, { params: { username: 'testuser', slug: 'secret-setup' } }), - request: createMockRequest({ - url: '/testuser/secret-setup/config', - headers: { authorization: `Bearer ${newTokenValue}` } - }) - } as any); - + // 6. Use the new CLI token to access the private config. + const privateRes = await call(GetConfigJSON, { + url: 'http://localhost:5173/testuser/secret-setup/config', + params: { username: 'testuser', slug: 'secret-setup' }, + token: approvedData.token + }); expect(privateRes.status).toBe(200); - const configData = await getJSON(privateRes); + const configData = (await privateRes.json()) as { + username: string; + slug: string; + packages: { name: string }[]; + }; expect(configData.username).toBe('testuser'); expect(configData.slug).toBe('secret-setup'); expect(configData.packages).toContainEqual(expect.objectContaining({ name: 'git' })); }); it('expired code cannot be approved', async () => { - const db = createMockDB({ - users: [mockUser], + await seed(db, { + users: [userRow()], api_tokens: [mockApiToken], - cli_auth_codes: [{ - id: 'code_expired', - code: 'EXPIRED1', - user_id: null, - token_id: null, - status: 'pending', - expires_at: '2020-01-01 00:00:00' - }] + cli_auth_codes: [ + { + id: 'code_expired', + code: 'EXPIRED1', + user_id: null, + token_id: null, + status: 'pending', + expires_at: '2020-01-01 00:00:00' + } + ] }); - const res = await CliApprove({ - ...handler(db), - request: createMockRequest({ - method: 'POST', - url: '/api/auth/cli/approve', - body: { code: 'EXPIRED1' }, - headers: { authorization: createBearerToken(mockApiToken.token) } - }) - } as any); + const res = await call(CliApprove, { + url: 'http://localhost:5173/api/auth/cli/approve', + method: 'POST', + token: mockApiToken.token, + body: { code: 'EXPIRED1' } + }); expect(res.status).toBe(400); - const data = await getJSON(res); + const data = (await res.json()) as { error: string }; expect(data.error).toContain('Invalid or expired'); }); it('poll without code_id returns 400', async () => { - const db = createMockDB({}); - - const res = await CliPoll({ - ...handler(db, { url: new URL('http://localhost:5173/api/auth/cli/poll') }), - request: createMockRequest({ url: '/api/auth/cli/poll' }) - } as any); + const res = await call(CliPoll, { url: 'http://localhost:5173/api/auth/cli/poll' }); expect(res.status).toBe(400); - const data = await getJSON(res); + const data = (await res.json()) as { error: string }; expect(data.error).toContain('code_id is required'); }); it('poll with nonexistent code_id returns expired', async () => { - const db = createMockDB({}); - - const res = await CliPoll({ - ...handler(db, { url: new URL('http://localhost:5173/api/auth/cli/poll?code_id=nonexistent') }), - request: createMockRequest({ url: '/api/auth/cli/poll?code_id=nonexistent' }) - } as any); + const res = await call(CliPoll, { + url: 'http://localhost:5173/api/auth/cli/poll?code_id=nonexistent' + }); expect(res.status).toBe(200); - const data = await getJSON(res); + const data = (await res.json()) as { status: string }; expect(data.status).toBe('expired'); }); it('approve requires authentication', async () => { - const db = createMockDB({}); - - const res = await CliApprove({ - ...handler(db), - request: createMockRequest({ - method: 'POST', - url: '/api/auth/cli/approve', - body: { code: 'TESTCODE' } - }) - } as any); + const res = await call(CliApprove, { + url: 'http://localhost:5173/api/auth/cli/approve', + method: 'POST', + body: { code: 'TESTCODE' } + }); expect(res.status).toBe(401); }); diff --git a/src/smoke-tests/config-crud.test.ts b/src/smoke-tests/config-crud.test.ts index e27409e..ea9ec75 100644 --- a/src/smoke-tests/config-crud.test.ts +++ b/src/smoke-tests/config-crud.test.ts @@ -1,63 +1,37 @@ /** - * Smoke Test: Config CRUD full lifecycle - * - * Verifies: Create → List → Read → Update → Delete - * This catches regressions where a new feature breaks existing config operations. + * Smoke Test: Config CRUD full lifecycle. + * Create → List → Read → Update → Delete, end-to-end on real D1. */ -import { describe, it, expect } from 'vitest'; -import { createMockDB } from '$lib/test/db-mock'; -import { - mockUser, - mockApiToken, - createMockRequest, - createMockPlatform, - createMockCookies -} from '$lib/test/fixtures'; -import { createBearerToken, getJSON } from '$lib/test/helpers'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import { resetDb, seed, strip } from '$lib/test/seed'; +import { call } from '$lib/test/call'; +import { mockUser, mockApiToken } from '$lib/test/fixtures'; import { GET as ListConfigs, POST as CreateConfig } from '../routes/api/configs/+server'; import { GET as GetConfig, PUT as UpdateConfig, DELETE as DeleteConfig } from '../routes/api/configs/[slug]/+server'; import { GET as GetConfigJSON } from '../routes/[username]/[slug]/config/+server'; import { GET as GetInstallScript } from '../routes/[username]/[slug]/install/+server'; -function authedHandler(db: any, overrides: Record = {}) { - const platform = createMockPlatform(db); - return { - platform, - url: new URL('http://localhost:5173/api/configs'), - route: { id: '' }, - locals: {}, - isDataRequest: false, - isSubRequest: false, - cookies: createMockCookies(), - getClientAddress: () => '', - fetch: globalThis.fetch, - ...overrides - }; -} - -function authedRequest(method: string, url: string, body?: any) { - return createMockRequest({ - method, - url, - headers: { authorization: createBearerToken(mockApiToken.token) }, - body - }); -} +const db = env.DB; +const userRow = () => strip(mockUser, 'provider', 'provider_id'); -describe('Smoke Test: Config CRUD Full Lifecycle', () => { - it('Create → List → Read → Update → Delete', async () => { - const db = createMockDB({ - users: [mockUser], - api_tokens: [mockApiToken], - configs: [] - }); +beforeEach(async () => { + await resetDb(db); + await seed(db, { users: [userRow()], api_tokens: [mockApiToken] }); +}); - // === 1. CREATE === - const createRes = await CreateConfig({ - ...authedHandler(db), - request: authedRequest('POST', '/api/configs', { +describe('Smoke — Config CRUD full lifecycle', () => { + it('Create → List → Read → Update → Delete', async () => { + const baseUrl = 'http://localhost:5173/api/configs'; + + // CREATE + const createRes = await call(CreateConfig, { + url: baseUrl, + method: 'POST', + token: mockApiToken.token, + body: { name: 'My Dev Setup', description: 'Full stack dev config', base_preset: 'developer', @@ -69,46 +43,52 @@ describe('Smoke Test: Config CRUD Full Lifecycle', () => { visibility: 'public', custom_script: 'echo "setup complete"', dotfiles_repo: 'https://github.com/testuser/dotfiles' - }) - } as any); - + } + }); expect(createRes.status).toBe(201); - const created = await getJSON(createRes); + const created = (await createRes.json()) as { slug: string; id: string }; expect(created.slug).toBe('my-dev-setup'); expect(created.id).toBeDefined(); - // === 2. LIST === - const listRes = await ListConfigs({ - ...authedHandler(db), - request: authedRequest('GET', '/api/configs') - } as any); - + // LIST + const listRes = await call(ListConfigs, { url: baseUrl, token: mockApiToken.token }); expect(listRes.status).toBe(200); - const listed = await getJSON(listRes); + const listed = (await listRes.json()) as { + configs: { slug: string; visibility: string }[]; + }; expect(listed.configs).toHaveLength(1); expect(listed.configs[0].slug).toBe('my-dev-setup'); expect(listed.configs[0].visibility).toBe('public'); - // === 3. READ (dashboard API) === - const readRes = await GetConfig({ - ...authedHandler(db, { params: { slug: 'my-dev-setup' } }), - request: authedRequest('GET', '/api/configs/my-dev-setup') - } as any); - + // READ (dashboard API) + const readRes = await call(GetConfig, { + url: `${baseUrl}/my-dev-setup`, + token: mockApiToken.token, + params: { slug: 'my-dev-setup' } + }); expect(readRes.status).toBe(200); - const detail = await getJSON(readRes); + const detail = (await readRes.json()) as { + config: { name: string; packages: unknown[] }; + install_url: string; + }; expect(detail.config.name).toBe('My Dev Setup'); expect(detail.config.packages).toHaveLength(3); expect(detail.install_url).toBeDefined(); - // === 4. READ (public config JSON — what CLI fetches) === - const configJsonRes = await GetConfigJSON({ - ...authedHandler(db, { params: { username: 'testuser', slug: 'my-dev-setup' } }), - request: createMockRequest({ url: '/testuser/my-dev-setup/config' }) - } as any); - + // READ (CLI config JSON) + const configJsonRes = await call(GetConfigJSON, { + url: 'http://localhost:5173/testuser/my-dev-setup/config', + params: { username: 'testuser', slug: 'my-dev-setup' } + }); expect(configJsonRes.status).toBe(200); - const configJson = await getJSON(configJsonRes); + const configJson = (await configJsonRes.json()) as { + username: string; + packages: { name: string }[]; + casks: { name: string }[]; + npm: unknown[]; + post_install: string[]; + dotfiles_repo: string; + }; expect(configJson.username).toBe('testuser'); expect(configJson.packages).toContainEqual(expect.objectContaining({ name: 'git' })); expect(configJson.packages).toContainEqual(expect.objectContaining({ name: 'node' })); @@ -117,21 +97,23 @@ describe('Smoke Test: Config CRUD Full Lifecycle', () => { expect(configJson.post_install).toEqual(['echo "setup complete"']); expect(configJson.dotfiles_repo).toBe('https://github.com/testuser/dotfiles'); - // === 5. READ (install script — what curl fetches) === - const installRes = await GetInstallScript({ - ...authedHandler(db, { params: { username: 'testuser', slug: 'my-dev-setup' } }), - request: createMockRequest({ url: '/testuser/my-dev-setup/install' }) - } as any); - + // READ (install script) + const installRes = await call(GetInstallScript, { + url: 'http://localhost:5173/testuser/my-dev-setup/install', + params: { username: 'testuser', slug: 'my-dev-setup' } + }); expect(installRes.status).toBe(200); const script = await installRes.text(); expect(script).toContain('#!/bin/bash'); expect(script).toContain('testuser/my-dev-setup'); - // === 6. UPDATE === - const updateRes = await UpdateConfig({ - ...authedHandler(db, { params: { slug: 'my-dev-setup' } }), - request: authedRequest('PUT', '/api/configs/my-dev-setup', { + // UPDATE — real D1 honors COALESCE so we can verify field values changed. + const updateRes = await call(UpdateConfig, { + url: `${baseUrl}/my-dev-setup`, + method: 'PUT', + token: mockApiToken.token, + params: { slug: 'my-dev-setup' }, + body: { description: 'Updated description', visibility: 'private', packages: [ @@ -140,107 +122,74 @@ describe('Smoke Test: Config CRUD Full Lifecycle', () => { { name: 'visual-studio-code', type: 'cask', desc: 'Editor' }, { name: 'docker', type: 'cask', desc: 'Containers' } ] - }) - } as any); - + } + }); expect(updateRes.status).toBe(200); - const updated = await getJSON(updateRes); - expect(updated.success).toBe(true); - - // === 7. Verify config still readable after update === - // Note: mock DB can't parse COALESCE in UPDATE SET clauses, so field values - // won't reflect the update. We verify the flow doesn't error and the config - // is still accessible. Real DB integration would verify field changes. - const readAfterUpdate = await GetConfig({ - ...authedHandler(db, { params: { slug: 'my-dev-setup' } }), - request: authedRequest('GET', '/api/configs/my-dev-setup') - } as any); - - expect(readAfterUpdate.status).toBe(200); - const afterUpdate = await getJSON(readAfterUpdate); - expect(afterUpdate.config.name).toBe('My Dev Setup'); - - // === 8. Manually set visibility to private to test access control === - // (bypasses mock DB COALESCE limitation) - db.data.configs[0].visibility = 'private'; - - const privateRes = await GetConfigJSON({ - ...authedHandler(db, { params: { username: 'testuser', slug: 'my-dev-setup' } }), - request: createMockRequest({ url: '/testuser/my-dev-setup/config' }) - } as any); + // Verify the update actually happened in the DB + const updatedRow = await db + .prepare('SELECT description, visibility FROM configs WHERE id = ?') + .bind(created.id) + .first<{ description: string; visibility: string }>(); + expect(updatedRow?.description).toBe('Updated description'); + expect(updatedRow?.visibility).toBe('private'); + + // Verify private access control kicks in + const privateRes = await call(GetConfigJSON, { + url: 'http://localhost:5173/testuser/my-dev-setup/config', + params: { username: 'testuser', slug: 'my-dev-setup' } + }); expect(privateRes.status).toBe(403); - // === 9. DELETE === - const deleteRes = await DeleteConfig({ - ...authedHandler(db, { params: { slug: 'my-dev-setup' } }), - request: authedRequest('DELETE', '/api/configs/my-dev-setup') - } as any); - + // DELETE + const deleteRes = await call(DeleteConfig, { + url: `${baseUrl}/my-dev-setup`, + method: 'DELETE', + token: mockApiToken.token, + params: { slug: 'my-dev-setup' } + }); expect(deleteRes.status).toBe(200); - const deleted = await getJSON(deleteRes); - expect(deleted.success).toBe(true); - - // === 10. Verify delete took effect === - const listAfterDelete = await ListConfigs({ - ...authedHandler(db), - request: authedRequest('GET', '/api/configs') - } as any); - const afterDelete = await getJSON(listAfterDelete); + // Verify delete took effect + const listAfterDelete = await call(ListConfigs, { url: baseUrl, token: mockApiToken.token }); + const afterDelete = (await listAfterDelete.json()) as { configs: unknown[] }; expect(afterDelete.configs).toHaveLength(0); }); it('Create with alias → access via alias config JSON', async () => { - const db = createMockDB({ - users: [mockUser], - api_tokens: [mockApiToken], - configs: [] - }); - - const createRes = await CreateConfig({ - ...authedHandler(db), - request: authedRequest('POST', '/api/configs', { + const createRes = await call(CreateConfig, { + url: 'http://localhost:5173/api/configs', + method: 'POST', + token: mockApiToken.token, + body: { name: 'Quick Setup', packages: [{ name: 'git', type: 'formula', desc: 'VCS' }], visibility: 'public', alias: 'quick' - }) - } as any); + } + }); expect(createRes.status).toBe(201); - const created = await getJSON(createRes); + const created = (await createRes.json()) as { alias: string; install_url: string }; expect(created.alias).toBe('quick'); expect(created.install_url).toContain('/quick'); }); it('Duplicate name returns 409 conflict', async () => { - const db = createMockDB({ - users: [mockUser], - api_tokens: [mockApiToken], - configs: [] - }); + const baseUrl = 'http://localhost:5173/api/configs'; + const body = { name: 'Unique Config', packages: [] }; + + await call(CreateConfig, { url: baseUrl, method: 'POST', token: mockApiToken.token, body }); - // Create first - await CreateConfig({ - ...authedHandler(db), - request: authedRequest('POST', '/api/configs', { - name: 'Unique Config', - packages: [] - }) - } as any); - - // Try duplicate - const dupRes = await CreateConfig({ - ...authedHandler(db), - request: authedRequest('POST', '/api/configs', { - name: 'Unique Config', - packages: [] - }) - } as any); + const dupRes = await call(CreateConfig, { + url: baseUrl, + method: 'POST', + token: mockApiToken.token, + body + }); expect(dupRes.status).toBe(409); - const dup = await getJSON(dupRes); + const dup = (await dupRes.json()) as { error: string }; expect(dup.error).toContain('already exists'); }); }); diff --git a/src/smoke-tests/critical-paths.test.ts b/src/smoke-tests/critical-paths.test.ts index d43df2e..853b8d0 100644 --- a/src/smoke-tests/critical-paths.test.ts +++ b/src/smoke-tests/critical-paths.test.ts @@ -1,29 +1,28 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { createMockDB } from '$lib/test/db-mock'; -import { createMockPlatform, createMockRequest, mockUser, mockConfig } from '$lib/test/fixtures'; +import { env } from 'cloudflare:test'; +import { resetDb, seed, strip } from '$lib/test/seed'; +import { call } from '$lib/test/call'; +import { mockUser, mockConfig } from '$lib/test/fixtures'; import { GET as GetHealth } from '../routes/api/health/+server'; import { GET as GetPublicConfigs } from '../routes/api/configs/public/+server'; import { GET as GetConfigJSON } from '../routes/[username]/[slug]/config/+server'; import { GET as GetInstallScript } from '../routes/[username]/[slug]/install/+server'; -describe('Smoke Tests - Critical User Journeys', () => { - let db: any; - let platform: any; +const db = env.DB; +const userRow = () => strip(mockUser, 'provider', 'provider_id'); - beforeEach(() => { - db = createMockDB(); - platform = createMockPlatform(db); - - db.data.users.push(mockUser); - db.data.configs.push(mockConfig); - }); +beforeEach(async () => { + await resetDb(db); + await seed(db, { users: [userRow()], configs: [mockConfig] }); +}); - describe('Health Check', () => { +describe('Smoke — Critical user journeys', () => { + describe('Health check', () => { it('verifies system is operational', async () => { - const response = await GetHealth({ platform } as any); - const data = await response.json(); - + const response = await GetHealth({ platform: { env } } as any); + const data = (await response.json()) as { status: string; checks: any; version: string }; + expect(data.status).toBeDefined(); expect(data.checks).toBeDefined(); expect(data.checks.api).toBe('ok'); @@ -31,147 +30,108 @@ describe('Smoke Tests - Critical User Journeys', () => { }); }); - describe('Config Discovery - Browse public configs', () => { + describe('Config discovery — browse public configs', () => { it('allows users to discover public configs', async () => { - const request = createMockRequest({ method: 'GET', url: '/api/configs/public' }); - const url = new URL('https://openboot.dev/api/configs/public'); - - const response = await GetPublicConfigs({ - platform, - request, - url - } as any); - - const data = await response.json(); - + const response = await call(GetPublicConfigs, { + url: 'https://openboot.dev/api/configs/public' + }); + expect(response.status).toBe(200); - expect(data.configs).toBeDefined(); + const data = (await response.json()) as { configs: unknown[]; total: number }; expect(Array.isArray(data.configs)).toBe(true); expect(data.total).toBeGreaterThanOrEqual(0); }); }); - describe('Config Installation Flow', () => { + describe('Config installation flow', () => { it('serves install script for curl users', async () => { - const params = { username: 'testuser', slug: 'my-config' }; - const request = createMockRequest({ method: 'GET', url: '/testuser/my-config/install' }); - - const response = await GetInstallScript({ - platform, - params, - request - } as any); - + const response = await call(GetInstallScript, { + url: 'http://localhost/testuser/my-config/install', + params: { username: 'testuser', slug: 'my-config' } + }); + expect(response.status).toBe(200); expect(response.headers.get('Content-Type')).toContain('text/plain'); - const script = await response.text(); expect(script).toContain('#!/bin/bash'); expect(script).toContain('OpenBoot'); }); it('provides config JSON for CLI', async () => { - const params = { username: 'testuser', slug: 'my-config' }; - const request = createMockRequest({ method: 'GET', url: '/testuser/my-config/config' }); - - const response = await GetConfigJSON({ - platform, - params, - request - } as any); - - const data = await response.json(); - + const response = await call(GetConfigJSON, { + url: 'http://localhost/testuser/my-config/config', + params: { username: 'testuser', slug: 'my-config' } + }); + expect(response.status).toBe(200); + const data = (await response.json()) as { + username: string; + slug: string; + packages: unknown[]; + }; expect(data.username).toBe('testuser'); expect(data.slug).toBe('my-config'); - expect(data.packages).toBeDefined(); expect(Array.isArray(data.packages)).toBe(true); }); }); - describe('Data Integrity', () => { + describe('Data integrity', () => { it('verifies config query filtering works', async () => { - db.data.configs[0].visibility = 'public'; - db.data.configs[0].featured = 1; - - const request = createMockRequest({ method: 'GET', url: '/api/configs/public?username=testuser' }); - const url = new URL('https://openboot.dev/api/configs/public?username=testuser'); - - const response = await GetPublicConfigs({ - platform, - request, - url - } as any); - - const data = await response.json(); - + await db.prepare(`UPDATE configs SET visibility = 'public', featured = 1 WHERE id = ?`).bind(mockConfig.id).run(); + + const response = await call(GetPublicConfigs, { + url: 'https://openboot.dev/api/configs/public?username=testuser' + }); + expect(response.status).toBe(200); - expect(data.configs).toBeDefined(); + const data = (await response.json()) as { configs: unknown[] }; expect(Array.isArray(data.configs)).toBe(true); }); it('verifies package data integrity', async () => { - const params = { username: 'testuser', slug: 'my-config' }; - const request = createMockRequest({ method: 'GET', url: '/testuser/my-config/config' }); - - const response = await GetConfigJSON({ - platform, - params, - request - } as any); - - const data = await response.json(); - + const response = await call(GetConfigJSON, { + url: 'http://localhost/testuser/my-config/config', + params: { username: 'testuser', slug: 'my-config' } + }); + expect(response.status).toBe(200); - expect(data.packages).toBeDefined(); - + const data = (await response.json()) as { + packages: { name: string; desc: string }[]; + casks: unknown[]; + taps: unknown[]; + }; + expect(Array.isArray(data.packages)).toBe(true); if (data.packages.length > 0) { - expect(Array.isArray(data.packages)).toBe(true); expect(data.packages[0]).toHaveProperty('name'); expect(data.packages[0]).toHaveProperty('desc'); } - - expect(data.casks).toBeDefined(); expect(Array.isArray(data.casks)).toBe(true); - - expect(data.taps).toBeDefined(); expect(Array.isArray(data.taps)).toBe(true); }); }); - describe('Error Handling', () => { + describe('Error handling', () => { it('handles non-existent configs gracefully', async () => { - const params = { username: 'nonexistent', slug: 'missing' }; - const request = createMockRequest({ method: 'GET', url: '/nonexistent/missing/config' }); - - const response = await GetConfigJSON({ - platform, - params, - request - } as any); - - const data = await response.json(); - + const response = await call(GetConfigJSON, { + url: 'http://localhost/nonexistent/missing/config', + params: { username: 'nonexistent', slug: 'missing' } + }); + expect(response.status).toBe(404); + const data = (await response.json()) as { error: string }; expect(data.error).toBeDefined(); }); it('handles private configs correctly', async () => { - db.data.configs[0].visibility = 'private'; - - const params = { username: 'testuser', slug: 'my-config' }; - const request = createMockRequest({ method: 'GET', url: '/testuser/my-config/config' }); - - const response = await GetConfigJSON({ - platform, - params, - request - } as any); - - const data = await response.json(); - + await db.prepare(`UPDATE configs SET visibility = 'private' WHERE id = ?`).bind(mockConfig.id).run(); + + const response = await call(GetConfigJSON, { + url: 'http://localhost/testuser/my-config/config', + params: { username: 'testuser', slug: 'my-config' } + }); + expect(response.status).toBe(403); + const data = (await response.json()) as { error: string }; expect(data.error).toContain('private'); }); }); diff --git a/tsconfig.json b/tsconfig.json index 2c2ed3c..c798f64 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "types": ["@cloudflare/vitest-pool-workers/types"] } // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files diff --git a/vitest.config.ts b/vitest.config.ts index 71dbf61..6287143 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,20 +5,27 @@ export default defineConfig({ plugins: [sveltekit()], test: { include: ['src/**/*.{test,spec}.{js,ts}'], + exclude: [ + 'src/routes/api/health/**/*.test.ts', + 'src/lib/server/auth.test.ts', + 'src/lib/server/db/configs.test.ts', + 'src/routes/api/configs/server.test.ts', + 'src/routes/api/configs/[slug]/server.test.ts', + 'src/routes/api/configs/[slug]/revisions/server.test.ts', + 'src/routes/api/auth/cli/poll/server.test.ts', + 'src/routes/api/auth/cli/start/server.test.ts', + 'src/routes/api/auth/cli/approve/server.test.ts', + 'src/routes/[username]/[slug]/install/server.test.ts', + 'src/routes/[username]/[slug]/config/server.test.ts', + 'src/smoke-tests/**/*.test.ts', + 'node_modules/**' + ], globals: true, environment: 'happy-dom', - setupFiles: ['./src/lib/test/setup.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], - exclude: [ - 'node_modules/', - 'src/lib/test/', - '**/*.d.ts', - '**/*.config.*', - '**/mockData', - 'build/' - ] + exclude: ['node_modules/', 'src/lib/test/', '**/*.d.ts', '**/*.config.*', '**/mockData', 'build/'] } } }); diff --git a/vitest.workers.config.ts b/vitest.workers.config.ts new file mode 100644 index 0000000..d8b3470 --- /dev/null +++ b/vitest.workers.config.ts @@ -0,0 +1,52 @@ +import { defineConfig } from 'vitest/config'; +import { cloudflarePool, cloudflareTest, readD1Migrations } from '@cloudflare/vitest-pool-workers'; +import path from 'node:path'; + +// Apply schema migrations only — data migrations (seed/transfer/enrich) reference +// production-specific identities (e.g. real user IDs) and can't run on a clean +// test DB. Tests should seed their own data fixtures. +const allMigrations = await readD1Migrations('./migrations'); +const isDml = (q: string) => /^\s*(INSERT|UPDATE|DELETE)\b/i.test(q); +const isDataOnlyMigration = (m: { queries: string[] }) => m.queries.length > 0 && m.queries.every(isDml); +const schemaMigrations = allMigrations.filter((m) => !isDataOnlyMigration(m)); + +const workersOptions = { + singleWorker: true, + miniflare: { + compatibilityDate: '2025-01-01', + compatibilityFlags: ['nodejs_compat'], + d1Databases: ['DB'], + bindings: { + TEST_MIGRATIONS: schemaMigrations, + JWT_SECRET: 'test-jwt-secret-key-32-chars-long', + APP_URL: 'http://localhost:5173' + } + } +}; + +export default defineConfig({ + plugins: [cloudflareTest(workersOptions)], + test: { + include: [ + 'src/routes/api/health/**/*.test.ts', + 'src/lib/server/auth.test.ts', + 'src/lib/server/db/configs.test.ts', + 'src/routes/api/configs/server.test.ts', + 'src/routes/api/configs/[slug]/server.test.ts', + 'src/routes/api/configs/[slug]/revisions/server.test.ts', + 'src/routes/api/auth/cli/poll/server.test.ts', + 'src/routes/api/auth/cli/start/server.test.ts', + 'src/routes/api/auth/cli/approve/server.test.ts', + 'src/routes/[username]/[slug]/install/server.test.ts', + 'src/routes/[username]/[slug]/config/server.test.ts', + 'src/smoke-tests/**/*.test.ts' + ], + setupFiles: ['./src/lib/test/apply-migrations.ts'], + pool: cloudflarePool(workersOptions) + }, + resolve: { + alias: { + $lib: path.resolve('./src/lib') + } + } +});