diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27e82a8..bc71a5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,10 +5,22 @@ on: branches: [main] paths: - 'dashboard/**' + - 'src/**' + - 'test/**' + - 'vitest.config.mts' + - 'wrangler.json' + - 'package.json' + - 'package-lock.json' - '.github/workflows/ci.yml' pull_request: paths: - 'dashboard/**' + - 'src/**' + - 'test/**' + - 'vitest.config.mts' + - 'wrangler.json' + - 'package.json' + - 'package-lock.json' - '.github/workflows/ci.yml' env: @@ -61,3 +73,37 @@ jobs: name: playwright-report path: dashboard/playwright-report/ retention-days: 30 + + backend-coverage: + name: Worker Coverage + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # mint OIDC token for Worker ingest + checks: write # post PR Check Run + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + # vitest.config.mts loads wrangler.json, whose assets.directory points at + # dashboard/build (gitignored, absent on a fresh checkout). The Workers pool + # tolerates a missing dir for these API-only tests, but create it as a cheap + # guard against runtime/OS differences — no dashboard build needed. + - name: Ensure assets directory exists + run: mkdir -p dashboard/build + + - name: Run Worker tests with coverage + run: npm run test:coverage + + - name: Report coverage + uses: CoverageTracker/coverage-tracker/.github/actions/report@v0.3.0 + with: + worker-url: https://demo.coveragetracker.dev diff --git a/.gitignore b/.gitignore index 76bd22b..57ffbfe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +coverage/ .wrangler/ .claude/ /dist/ diff --git a/package-lock.json b/package-lock.json index a8505f2..1123cc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,11 +15,283 @@ "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.16.20", "@types/node": "^26.0.1", + "@vitest/coverage-istanbul": "^4.1.9", "typescript": "^5.6.3", "vitest": "^4.1.9", "wrangler": "^4.104.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -1223,6 +1495,60 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1664,17 +1990,53 @@ "undici-types": "~8.3.0" } }, + "node_modules/@vitest/coverage-istanbul": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.10.tgz", + "integrity": "sha512-AyNJ5pQRFqCX7pwB9PSTmoVKPaZ4H5IEVJfJsT+q1DYkXvZMEFYgJlyk5sfStmt9rVYRyYYRRsuBeImCOc39ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@istanbuljs/schema": "^0.1.3", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/trace-mapping": "0.3.31", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.10" + } + }, + "node_modules/@vitest/coverage-istanbul/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1683,13 +2045,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1710,9 +2072,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1723,13 +2085,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -1737,14 +2099,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1753,9 +2115,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -1763,13 +2125,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1787,6 +2149,19 @@ "node": ">=12" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1794,6 +2169,61 @@ "dev": true, "license": "MIT" }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001802", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz", + "integrity": "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1832,6 +2262,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1842,6 +2290,13 @@ "node": ">=8" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "dev": true, + "license": "ISC" + }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -1901,6 +2356,16 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1954,6 +2419,26 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hono": { "version": "4.12.27", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", @@ -1963,6 +2448,65 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", @@ -1972,6 +2516,39 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -2255,6 +2832,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2265,6 +2852,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/miniflare": { "version": "4.20260625.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz", @@ -2286,6 +2901,13 @@ "node": ">=22.0.0" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -2305,6 +2927,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -2611,6 +3243,37 @@ "pathe": "^2.0.3" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/vite": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", @@ -2690,19 +3353,19 @@ } }, "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -2730,12 +3393,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -3002,6 +3665,13 @@ } } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", diff --git a/package.json b/package.json index 5a034cf..5526da6 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "deploy": "wrangler d1 migrations apply DB --remote && wrangler deploy", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:coverage": "vitest run --coverage", "db:migrate:local": "wrangler d1 migrations apply DB --local --env dev", "db:seed:local": "wrangler d1 execute DB --local --file test/seed-local.sql --env dev", "db:migrate:remote": "wrangler d1 migrations apply DB --remote" @@ -19,6 +20,7 @@ "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.16.20", "@types/node": "^26.0.1", + "@vitest/coverage-istanbul": "^4.1.9", "typescript": "^5.6.3", "vitest": "^4.1.9", "wrangler": "^4.104.0" diff --git a/test/admin.test.ts b/test/admin.test.ts new file mode 100644 index 0000000..9025c2f --- /dev/null +++ b/test/admin.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import worker from '../src/index'; +import { getTestAppPrivateKeyPem } from './helpers/crypto'; +import { FAILING_ACCESS_TOKEN_INSTALLATION_ID } from './helpers/outbound'; +import type { Bindings } from '../src/types'; + +// @ts-expect-error cloudflare:test injects env at runtime +const testEnv = env as Bindings; + +beforeEach(() => { + // ENVIRONMENT is declared as a var in wrangler.json env.dev, but the test pool uses + // the top-level config so it isn't exposed as a binding — inject it directly, as + // test/routing.test.ts does, so requireAccess() takes the local-dev bypass. + (testEnv as Record).ENVIRONMENT = 'development'; + testEnv.GITHUB_APP_ID = 'test-app-id'; + testEnv.GITHUB_APP_PRIVATE_KEY = getTestAppPrivateKeyPem(); +}); + +afterEach(() => { + delete (testEnv as Record).ENVIRONMENT; +}); + +function postJson(path: string, body: unknown): Promise { + return worker.fetch( + new Request(`http://localhost${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + testEnv as never, + ); +} + +function patchJson(path: string, body: unknown): Promise { + return worker.fetch( + new Request(`http://localhost${path}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + testEnv as never, + ); +} + +describe('POST /api/admin/resync', () => { + it('returns 400 for an invalid JSON body', async () => { + const res = await worker.fetch( + new Request('http://localhost/api/admin/resync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{not json', + }), + testEnv as never, + ); + expect(res.status).toBe(400); + }); + + it('returns 400 for a non-integer installationId', async () => { + const res = await postJson('/api/admin/resync', { installationId: 'abc' }); + expect(res.status).toBe(400); + }); + + it('returns 400 for a negative installationId', async () => { + const res = await postJson('/api/admin/resync', { installationId: -5 }); + expect(res.status).toBe(400); + }); + + it('returns 502 when the resync fails', async () => { + const res = await postJson('/api/admin/resync', { installationId: FAILING_ACCESS_TOKEN_INSTALLATION_ID }); + expect(res.status).toBe(502); + }); + + it('resyncs projects to match the GitHub fixture state', async () => { + const res = await postJson('/api/admin/resync', { installationId: 100 }); + expect(res.status).toBe(200); + + const owner = await testEnv.DB.prepare('SELECT * FROM owners WHERE github_id = 100').first(); + expect(owner).not.toBeNull(); + + const repoA = await testEnv.DB.prepare('SELECT * FROM projects WHERE github_repo_id = 1001').first<{ + full_slug: string; + default_branch: string; + }>(); + expect(repoA?.full_slug).toBe('fixture-org/repo-a'); + expect(repoA?.default_branch).toBe('main'); + + const repoB = await testEnv.DB.prepare('SELECT * FROM projects WHERE github_repo_id = 1002').first<{ + default_branch: string; + }>(); + expect(repoB?.default_branch).toBe('develop'); + }); + + it('removes local projects that GitHub no longer reports for the installation', async () => { + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO owners (id, github_id, login, type) VALUES (902, 102, 'empty-org', 'Organization')`, + ).run(); + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO projects (owner_id, github_repo_id, repo_name, full_slug, installation_id, default_branch) + VALUES (902, 99999, 'stale', 'empty-org/stale', 102, 'main')`, + ).run(); + + const res = await postJson('/api/admin/resync', { installationId: 102 }); + expect(res.status).toBe(200); + + const stale = await testEnv.DB.prepare('SELECT * FROM projects WHERE github_repo_id = 99999').first(); + expect(stale).toBeNull(); + }); +}); + +describe('PATCH /api/admin/projects/:id/badge', () => { + beforeEach(async () => { + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO owners (id, github_id, login, type) VALUES (2, 2, 'badgeorg', 'Organization')`, + ).run(); + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO projects (id, owner_id, github_repo_id, repo_name, full_slug, installation_id, default_branch) + VALUES (2, 2, 2, 'repo2', 'badgeorg/repo2', 2, 'main')`, + ).run(); + }); + + it('returns 400 for a non-integer project id', async () => { + const res = await patchJson('/api/admin/projects/abc/badge', { enabled: true }); + expect(res.status).toBe(400); + }); + + it('returns 404 when the project does not exist', async () => { + const res = await patchJson('/api/admin/projects/999999/badge', { enabled: true }); + expect(res.status).toBe(404); + }); + + it('returns 400 for an invalid JSON body', async () => { + const res = await worker.fetch( + new Request('http://localhost/api/admin/projects/2/badge', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: '{not json', + }), + testEnv as never, + ); + expect(res.status).toBe(400); + }); + + it('returns 400 when enabled is not a boolean', async () => { + const res = await patchJson('/api/admin/projects/2/badge', { enabled: 'yes' }); + expect(res.status).toBe(400); + }); + + it('toggles the badge and persists it', async () => { + const res = await patchJson('/api/admin/projects/2/badge', { enabled: true }); + expect(res.status).toBe(200); + const body = await res.json() as { ok: boolean; badge_enabled: boolean }; + expect(body.badge_enabled).toBe(true); + + const row = await testEnv.DB.prepare('SELECT badge_enabled FROM projects WHERE id = 2').first<{ + badge_enabled: number; + }>(); + expect(row?.badge_enabled).toBe(1); + }); +}); diff --git a/test/api.test.ts b/test/api.test.ts new file mode 100644 index 0000000..860c674 --- /dev/null +++ b/test/api.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import worker from '../src/index'; +import type { Bindings } from '../src/types'; + +// @ts-expect-error cloudflare:test injects env at runtime +const testEnv = env as Bindings; + +beforeEach(async () => { + // ENVIRONMENT is declared as a var in wrangler.json env.dev, but the test pool uses + // the top-level config so it isn't exposed as a binding — inject it directly, as + // test/routing.test.ts does, so requireAccess() takes the local-dev bypass. + (testEnv as Record).ENVIRONMENT = 'development'; + + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO owners (id, github_id, login, type) VALUES (1, 1, 'testorg', 'Organization')`, + ).run(); + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO projects (id, owner_id, github_repo_id, repo_name, full_slug, installation_id, default_branch) + VALUES (1, 1, 1, 'repo', 'testorg/repo', 1, 'main')`, + ).run(); + await testEnv.DB.prepare('DELETE FROM coverage_runs').run(); +}); + +afterEach(() => { + delete (testEnv as Record).ENVIRONMENT; +}); + +function get(path: string): Promise { + return worker.fetch(new Request(`http://localhost${path}`), testEnv as never); +} + +describe('GET /api/projects', () => { + it('lists projects with owner metadata attached', async () => { + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO owners (id, github_id, login, type) VALUES (2, 2, 'anotherorg', 'User')`, + ).run(); + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO projects (id, owner_id, github_repo_id, repo_name, full_slug, installation_id, default_branch) + VALUES (2, 2, 2, 'repo2', 'anotherorg/repo2', 2, 'main')`, + ).run(); + + const res = await get('/api/projects'); + expect(res.status).toBe(200); + const body = await res.json() as Array<{ full_slug: string; owner_login: string; owner_type: string }>; + + const slugs = body.map((p) => p.full_slug); + expect(slugs).toContain('testorg/repo'); + expect(slugs).toContain('anotherorg/repo2'); + + const mine = body.find((p) => p.full_slug === 'testorg/repo'); + expect(mine?.owner_login).toBe('testorg'); + expect(mine?.owner_type).toBe('Organization'); + }); +}); + +describe('GET /api/projects/:owner/:repo/metrics', () => { + it('returns 404 for an unregistered project', async () => { + const res = await get('/api/projects/nobody/nothing/metrics'); + expect(res.status).toBe(404); + }); + + it('returns 400 for an unknown metric', async () => { + const res = await get('/api/projects/testorg/repo/metrics?metric=not-a-metric'); + expect(res.status).toBe(400); + }); + + it('returns trend data using the project default branch and coverage metric by default', async () => { + const now = Math.floor(Date.now() / 1000); + await testEnv.DB.prepare( + `INSERT INTO coverage_runs (project_id, commit_sha, branch, ran_at, line_coverage) + VALUES (1, 'sha-1', 'main', ?1, 80), + (1, 'sha-2', 'main', ?2, 90)`, + ).bind(now - 86400, now).run(); + + const res = await get('/api/projects/testorg/repo/metrics'); + expect(res.status).toBe(200); + const body = await res.json() as { + project: string; + branch: string; + metric: string; + data: Array<{ commit_sha: string; value: number; unit: string }>; + }; + expect(body.project).toBe('testorg/repo'); + expect(body.branch).toBe('main'); + expect(body.metric).toBe('coverage'); + expect(body.data).toHaveLength(2); + expect(body.data.at(-1)).toMatchObject({ commit_sha: 'sha-2', value: 90, unit: '%' }); + }); + + it('clamps an oversized limit query param instead of erroring', async () => { + const now = Math.floor(Date.now() / 1000); + await testEnv.DB.prepare( + `INSERT INTO coverage_runs (project_id, commit_sha, branch, ran_at, line_coverage) + VALUES (1, 'sha-a', 'main', ?1, 10), (1, 'sha-b', 'main', ?2, 20)`, + ).bind(now - 86400, now).run(); + + // Math.min(Number(limit), 1000) — a limit far past the cap must not error or + // attempt to over-fetch; it just can't return more rows than exist. + const res = await get('/api/projects/testorg/repo/metrics?limit=999999'); + expect(res.status).toBe(200); + const body = await res.json() as { data: unknown[] }; + expect(body.data).toHaveLength(2); + }); + + it('honors a small limit query param', async () => { + const now = Math.floor(Date.now() / 1000); + await testEnv.DB.prepare( + `INSERT INTO coverage_runs (project_id, commit_sha, branch, ran_at, line_coverage) + VALUES (1, 'sha-a', 'main', ?1, 10), (1, 'sha-b', 'main', ?2, 20)`, + ).bind(now - 86400, now).run(); + + const res = await get('/api/projects/testorg/repo/metrics?limit=1'); + const body = await res.json() as { data: unknown[] }; + expect(body.data).toHaveLength(1); + }); +}); diff --git a/test/badge.test.ts b/test/badge.test.ts new file mode 100644 index 0000000..a8275ee --- /dev/null +++ b/test/badge.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import worker from '../src/index'; +import type { Bindings } from '../src/types'; + +// @ts-expect-error cloudflare:test injects env at runtime +const testEnv = env as Bindings; + +beforeEach(async () => { + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO owners (id, github_id, login, type) VALUES (1, 1, 'testorg', 'Organization')`, + ).run(); + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO projects (id, owner_id, github_repo_id, repo_name, full_slug, installation_id, default_branch) + VALUES (1, 1, 1, 'repo', 'testorg/repo', 1, 'main')`, + ).run(); + await testEnv.DB.prepare('UPDATE projects SET badge_enabled = 1 WHERE id = 1').run(); + await testEnv.DB.prepare('DELETE FROM coverage_runs').run(); +}); + +async function getBadge(owner: string, repo: string, metric: string): Promise { + return worker.fetch( + new Request(`http://localhost/api/badge/${owner}/${repo}/${metric}.json`), + testEnv as never, + ); +} + +async function seedCoverage(fields: Partial<{ + line_coverage: number; + duplication_pct: number; + cyclomatic: number; +}>): Promise { + await testEnv.DB.prepare( + `INSERT INTO coverage_runs (project_id, commit_sha, branch, ran_at, line_coverage, duplication_pct, cyclomatic) + VALUES (1, 'sha-badge', 'main', ?1, ?2, ?3, ?4)`, + ) + .bind(Math.floor(Date.now() / 1000), fields.line_coverage ?? 50, fields.duplication_pct ?? null, fields.cyclomatic ?? null) + .run(); +} + +describe('GET /api/badge/:owner/:repo/:metric.json', () => { + it('returns 404 when the project does not exist', async () => { + const res = await getBadge('nope', 'nope', 'coverage'); + expect(res.status).toBe(404); + }); + + it('returns 404 (not 403) when the badge is disabled — A12 privacy control', async () => { + await testEnv.DB.prepare('UPDATE projects SET badge_enabled = 0 WHERE id = 1').run(); + await seedCoverage({ line_coverage: 90 }); + const res = await getBadge('testorg', 'repo', 'coverage'); + expect(res.status).toBe(404); + }); + + it('returns 404 for an unknown metric name', async () => { + await seedCoverage({ line_coverage: 90 }); + const res = await getBadge('testorg', 'repo', 'not-a-real-metric'); + expect(res.status).toBe(404); + }); + + it('returns 404 when the badge is enabled but there is no coverage data yet', async () => { + const res = await getBadge('testorg', 'repo', 'coverage'); + expect(res.status).toBe(404); + }); + + it('returns a shields.io-shaped response on success', async () => { + await seedCoverage({ line_coverage: 87.25 }); + const res = await getBadge('testorg', 'repo', 'coverage'); + expect(res.status).toBe(200); + const body = await res.json() as { schemaVersion: number; label: string; message: string; color: string }; + expect(body.schemaVersion).toBe(1); + expect(body.label).toBe('coverage'); + expect(body.message).toBe('87.3%'); + expect(body.color).toBe('brightgreen'); + }); + + it.each([ + [85, 'brightgreen'], + [70, 'yellow'], + [40, 'red'], + ])('coverage badge color at %d%% is %s', async (value, color) => { + await seedCoverage({ line_coverage: value }); + const res = await getBadge('testorg', 'repo', 'coverage'); + const body = await res.json() as { color: string }; + expect(body.color).toBe(color); + }); + + it.each([ + [2, 'brightgreen'], + [7, 'yellow'], + [15, 'red'], + ])('duplication badge color at %d%% is %s', async (value, color) => { + await seedCoverage({ line_coverage: 50, duplication_pct: value }); + const res = await getBadge('testorg', 'repo', 'duplication'); + const body = await res.json() as { color: string }; + expect(body.color).toBe(color); + }); + + it('defaults to blue for metrics with no color thresholds', async () => { + await seedCoverage({ line_coverage: 50, cyclomatic: 12 }); + const res = await getBadge('testorg', 'repo', 'cyclomatic'); + const body = await res.json() as { color: string; message: string }; + expect(body.color).toBe('blue'); + expect(body.message).toBe('12'); // unitless metric, no % suffix + }); +}); diff --git a/test/ci.test.ts b/test/ci.test.ts index b5906a0..4e92dac 100644 --- a/test/ci.test.ts +++ b/test/ci.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { env } from 'cloudflare:test'; import worker from '../src/index'; +import { getLatestCoverageRun } from '../src/lib/db'; +import { signOidcJwt, seedOidcJwks } from './helpers/crypto'; import type { Bindings } from '../src/types'; // @ts-expect-error cloudflare:test injects env at runtime @@ -14,14 +16,18 @@ beforeEach(async () => { `INSERT OR IGNORE INTO projects (id, owner_id, github_repo_id, repo_name, full_slug, installation_id, default_branch) VALUES (1, 1, 1, 'repo', 'testorg/repo', 1, 'main')`, ).run(); + await testEnv.DB.prepare('DELETE FROM coverage_runs').run(); + await seedOidcJwks(); }); -async function fetchCI(payload: unknown): Promise { +async function fetchCI(payload: unknown, token?: string): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (token !== undefined) headers.Authorization = `Bearer ${token}`; return worker.fetch( new Request('http://localhost/api/ci/coverage', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + headers, + body: typeof payload === 'string' ? payload : JSON.stringify(payload), }), testEnv as never, ); @@ -37,4 +43,85 @@ describe('POST /api/ci/coverage', () => { const res = await fetchCI({ branch_coverage: 80 }); // missing required line_coverage expect(res.status).toBe(401); }); + + it('rejects a tag-ref token with 422', async () => { + const token = await signOidcJwt({ ref_type: 'tag', ref: 'refs/tags/v1.0.0' }); + const res = await fetchCI({ line_coverage: 90 }, token); + expect(res.status).toBe(422); + const body = await res.json() as { error: string }; + expect(body.error).toMatch(/branch refs/); + }); + + it('rejects an unregistered repository with 403', async () => { + const token = await signOidcJwt({ repository: 'testorg/unknown-repo' }); + const res = await fetchCI({ line_coverage: 90 }, token); + expect(res.status).toBe(403); + }); + + it('rejects a non-default-branch push with 422', async () => { + const token = await signOidcJwt({ ref: 'refs/heads/feature-x' }); + const res = await fetchCI({ line_coverage: 90 }, token); + expect(res.status).toBe(422); + const body = await res.json() as { error: string }; + expect(body.error).toMatch(/default branch/); + }); + + it('rejects a malformed JSON body with 400', async () => { + const token = await signOidcJwt({}); + const res = await fetchCI('{not valid json', token); + expect(res.status).toBe(400); + }); + + it('rejects a schema violation with 422 and an issues array', async () => { + const token = await signOidcJwt({}); + const res = await fetchCI({ line_coverage: 150 }, token); // out of 0-100 range + expect(res.status).toBe(422); + const body = await res.json() as { error: string; issues: unknown[] }; + expect(body.error).toBe('Validation failed'); + expect(Array.isArray(body.issues)).toBe(true); + expect(body.issues.length).toBeGreaterThan(0); + }); + + it('rejects a body missing the required line_coverage field with 422', async () => { + const token = await signOidcJwt({}); + const res = await fetchCI({ branch_coverage: 80 }, token); + expect(res.status).toBe(422); + }); + + it('accepts a valid report and persists it with 202', async () => { + const token = await signOidcJwt({ sha: 'c'.repeat(40) }); + const res = await fetchCI( + { line_coverage: 92.5, branch_coverage: 80, cyclomatic: 4 }, + token, + ); + expect(res.status).toBe(202); + + const row = await getLatestCoverageRun(testEnv.DB, 1, 'main'); + expect(row).not.toBeNull(); + expect(row!.commit_sha).toBe('c'.repeat(40)); + expect(row!.line_coverage).toBe(92.5); + expect(row!.branch_coverage).toBe(80); + expect(row!.cyclomatic).toBe(4); + // Omitted optional fields fall back to null, not undefined/0. + expect(row!.cognitive).toBeNull(); + expect(row!.duplication_pct).toBeNull(); + expect(row!.maintainability).toBeNull(); + }); + + it('is idempotent — re-posting the same commit updates the row in place', async () => { + const sha = 'd'.repeat(40); + const token = await signOidcJwt({ sha }); + + await fetchCI({ line_coverage: 50 }, token); + const res2 = await fetchCI({ line_coverage: 99 }, token); + expect(res2.status).toBe(202); + + const { results } = await testEnv.DB.prepare( + 'SELECT line_coverage FROM coverage_runs WHERE project_id = 1 AND commit_sha = ?', + ) + .bind(sha) + .all<{ line_coverage: number }>(); + expect(results).toHaveLength(1); + expect(results[0].line_coverage).toBe(99); + }); }); diff --git a/test/db.test.ts b/test/db.test.ts index b3cfdaf..093411e 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -4,6 +4,9 @@ import { upsertCoverageRun, getCoverageTrend, getLatestCoverage, + insertMetric, + getMetricsTrend, + getLatestMetric, } from '../src/lib/db'; import type { Bindings } from '../src/types'; @@ -23,6 +26,7 @@ beforeEach(async () => { ).run(); await testEnv.DB.prepare('DELETE FROM coverage_daily').run(); await testEnv.DB.prepare('DELETE FROM coverage_runs').run(); + await testEnv.DB.prepare('DELETE FROM metrics').run(); }); describe('upsertCoverageRun — idempotency', () => { @@ -100,3 +104,54 @@ describe('getLatestCoverage — coverage_daily fallback', () => { expect(result!.line_coverage).toBe(80.0); }); }); + +describe('insertMetric — idempotent no-op (A11)', () => { + it('re-ingesting the same (project, commit, metric) leaves the original value untouched', async () => { + await insertMetric(testEnv.DB, 1, 'main', 'sha-metric', 'coverage', 70, '%'); + await insertMetric(testEnv.DB, 1, 'main', 'sha-metric', 'coverage', 99, '%'); + + const { results } = await testEnv.DB.prepare( + `SELECT value FROM metrics WHERE project_id = 1 AND commit_sha = 'sha-metric' AND metric_name = 'coverage'`, + ).all<{ value: number }>(); + + expect(results).toHaveLength(1); + expect(results[0].value).toBe(70); + }); +}); + +describe('getMetricsTrend', () => { + it('returns entries ordered most-recent-first, respecting the limit', async () => { + const stmts = Array.from({ length: 5 }, (_, i) => + testEnv.DB.prepare( + `INSERT INTO metrics (project_id, branch, commit_sha, metric_name, value, unit, recorded_at) + VALUES (1, 'main', ?, 'coverage', ?, '%', ?)`, + ).bind(`sha-m${i}`, 50 + i, `2026-01-0${i + 1}`), + ); + for (const s of stmts) await s.run(); + + const trend = await getMetricsTrend(testEnv.DB, 1, 'main', 'coverage', 3); + + expect(trend).toHaveLength(3); + expect(trend[0].recorded_at).toBe('2026-01-05'); + expect(trend.at(-1)!.recorded_at).toBe('2026-01-03'); + }); +}); + +describe('getLatestMetric', () => { + it('returns null when no rows exist', async () => { + const result = await getLatestMetric(testEnv.DB, 1, 'main', 'coverage'); + expect(result).toBeNull(); + }); + + it('returns the most recently recorded value', async () => { + await testEnv.DB.prepare( + `INSERT INTO metrics (project_id, branch, commit_sha, metric_name, value, unit, recorded_at) + VALUES (1, 'main', 'sha-old', 'coverage', 40, '%', '2026-01-01'), + (1, 'main', 'sha-new', 'coverage', 95, '%', '2026-01-10')`, + ).run(); + + const result = await getLatestMetric(testEnv.DB, 1, 'main', 'coverage'); + expect(result?.value).toBe(95); + expect(result?.commit_sha).toBe('sha-new'); + }); +}); diff --git a/test/global-setup.ts b/test/global-setup.ts index cb25e06..c9ed8bf 100644 --- a/test/global-setup.ts +++ b/test/global-setup.ts @@ -1,13 +1,25 @@ import { readD1Migrations } from '@cloudflare/vitest-pool-workers'; +import { exportJWK, exportPKCS8, generateKeyPair, type JWK } from 'jose'; import type { GlobalSetupContext } from 'vitest/node'; declare module 'vitest' { export interface ProvidedContext { migrations: Array<{ name: string; queries: string[] }>; + // A fresh RSA keypair generated once per test run (never written to disk), + // shared between the outboundService callback in vitest.config.mts (which + // runs in this Node host process) and test files (which run inside a + // separate workerd isolate) via vitest's provide/inject bridge. See + // test/helpers/crypto.ts. + oidcTestPrivateKeyPem: string; + oidcTestPublicJwk: JWK; } } export default async function ({ provide }: GlobalSetupContext) { const migrations = await readD1Migrations('./migrations'); provide('migrations', migrations); + + const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true }); + provide('oidcTestPrivateKeyPem', await exportPKCS8(privateKey)); + provide('oidcTestPublicJwk', await exportJWK(publicKey)); } diff --git a/test/helpers/constants.ts b/test/helpers/constants.ts new file mode 100644 index 0000000..2487768 --- /dev/null +++ b/test/helpers/constants.ts @@ -0,0 +1,4 @@ +// No vitest-context dependency (unlike crypto.ts, which calls inject()) so this +// is safe to import from vitest.config.mts's outboundService callback too. +export const OIDC_JWKS_URL = 'https://token.actions.githubusercontent.com/.well-known/jwks'; +export const OIDC_KID = 'test-oidc-key'; diff --git a/test/helpers/crypto.ts b/test/helpers/crypto.ts new file mode 100644 index 0000000..3770cb9 --- /dev/null +++ b/test/helpers/crypto.ts @@ -0,0 +1,94 @@ +import { SignJWT, importPKCS8, type JWK } from 'jose'; +import { inject } from 'vitest'; +import type { GitHubOidcClaims } from '../../src/types'; +import { OIDC_JWKS_URL, OIDC_KID } from './constants'; + +/** + * The RSA keypair used to sign test tokens is generated once per run in + * test/global-setup.ts (Node host process) and reaches both the outboundService + * callback in vitest.config.mts (same host process) and this module (running + * inside the workerd isolate) via vitest's provide/inject bridge — never + * written to disk or committed, so there's no private key material in source. + */ +export { OIDC_JWKS_URL, OIDC_KID }; + +let privateKeyPromise: Promise | null = null; +function getPrivateKey(): Promise { + privateKeyPromise ??= importPKCS8(inject('oidcTestPrivateKeyPem'), 'RS256'); + return privateKeyPromise; +} + +/** JWKS body containing the one generated test key, under the given kid. */ +export function jwksFor(kid: string): { keys: JWK[] } { + return { keys: [{ ...inject('oidcTestPublicJwk'), kid, alg: 'RS256', use: 'sig' }] }; +} + +/** Seed the Cache API entry that oidc.ts checks before ever calling fetch(). */ +export async function seedOidcJwks(kid = OIDC_KID): Promise { + await caches.default.put( + new Request(OIDC_JWKS_URL), + new Response(JSON.stringify(jwksFor(kid)), { headers: { 'Content-Type': 'application/json' } }), + ); +} + +const DEFAULT_CLAIMS: GitHubOidcClaims = { + iss: 'https://token.actions.githubusercontent.com', + sub: 'repo:testorg/repo:ref:refs/heads/main', + aud: 'coverage-tracker', + repository: 'testorg/repo', + ref: 'refs/heads/main', + ref_type: 'branch', + sha: 'a'.repeat(40), + repository_owner: 'testorg', + repository_id: '1', + actor: 'tester', + event_name: 'push', + iat: 0, + exp: 0, +}; + +/** Sign a GitHub Actions OIDC token. Override any claim; defaults match a typical push-to-main run. */ +export async function signOidcJwt( + overrides: Partial = {}, + opts: { kid?: string } = {}, +): Promise { + const privateKey = await getPrivateKey(); + const now = Math.floor(Date.now() / 1000); + const claims = { ...DEFAULT_CLAIMS, ...overrides }; + + return new SignJWT({ ...claims }) + .setProtectedHeader({ alg: 'RS256', kid: opts.kid ?? OIDC_KID }) + .setIssuedAt(now) + .setExpirationTime(now + 300) + .setIssuer(claims.iss) + .setAudience(claims.aud) + .setSubject(claims.sub) + .sign(privateKey); +} + +/** + * The same generated PEM, reused as env.GITHUB_APP_PRIVATE_KEY. Only used to + * locally sign the GitHub App JWT (mintAppJwt) inside the worker under test — + * never verified by anything external, since all GitHub API calls are + * intercepted by the test outboundService (see test/helpers/outbound.ts). + */ +export function getTestAppPrivateKeyPem(): string { + return inject('oidcTestPrivateKeyPem'); +} + +function bytesToHex(bytes: ArrayBuffer): string { + return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** Sign a webhook body with the given secret, mirroring GitHub's X-Hub-Signature-256 format. */ +export async function signWebhookBody(secret: string, body: string): Promise { + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body)); + return `sha256=${bytesToHex(sig)}`; +} diff --git a/test/helpers/outbound.ts b/test/helpers/outbound.ts new file mode 100644 index 0000000..9979068 --- /dev/null +++ b/test/helpers/outbound.ts @@ -0,0 +1,143 @@ +import type { JWK } from 'jose'; +import { OIDC_JWKS_URL, OIDC_KID } from './constants'; + +/** + * Fake GitHub REST API + JWKS server, wired up as Miniflare's `outboundService` + * (see vitest.config.mts). `fetchMock` from `cloudflare:test` isn't exported by + * the installed @cloudflare/vitest-pool-workers version, so outbound fetches + * from the worker under test are intercepted here instead, at the network + * level, in the Node host process. + * + * Because this runs in a different module realm than the test files (which run + * inside the workerd isolate), it can't hold per-test mutable state set from a + * test — everything here must be derivable purely from the request itself. + * Installation-scoped fixtures are selected by encoding the installation id + * into the installation-token string minted here (`mock-token-`), then + * recovering it from the `Authorization` header on later calls. + */ + +export interface FixtureAccount { + id: number; + login: string; + type: 'User' | 'Organization'; + avatar_url: string; +} + +export interface FixtureRepo { + id: number; + name: string; + full_name: string; + default_branch: string; +} + +interface InstallationFixture { + account: FixtureAccount; + repos: FixtureRepo[]; +} + +function repos(count: number, prefix: string): FixtureRepo[] { + return Array.from({ length: count }, (_, i) => ({ + id: 5000 + i, + name: `${prefix}-${i}`, + full_name: `fixture-org/${prefix}-${i}`, + default_branch: 'main', + })); +} + +/** installationId -> fixture data for /app/installations/:id and /installation/repositories */ +export const FIXTURE_INSTALLATIONS: Record = { + 100: { + account: { id: 100, login: 'fixture-org', type: 'Organization', avatar_url: 'https://example.com/a.png' }, + repos: [ + { id: 1001, name: 'repo-a', full_name: 'fixture-org/repo-a', default_branch: 'main' }, + { id: 1002, name: 'repo-b', full_name: 'fixture-org/repo-b', default_branch: 'develop' }, + ], + }, + // Pagination: 150 repos spans two pages of 100 + 50 at the hardcoded per_page=100. + 101: { + account: { id: 101, login: 'big-org', type: 'Organization', avatar_url: 'https://example.com/b.png' }, + repos: repos(150, 'repo'), + }, + // No repos left — exercises performResync's "remove local projects GitHub no longer has" path. + 102: { + account: { id: 102, login: 'empty-org', type: 'Organization', avatar_url: 'https://example.com/c.png' }, + repos: [], + }, +}; + +/** installationId that fails to mint an access token (POST access_tokens -> 500). */ +export const FAILING_ACCESS_TOKEN_INSTALLATION_ID = 599; +/** installationId with a valid token but no matching installation record (GET /app/installations/:id -> 404). */ +export const MISSING_INSTALLATION_ID = 699; +/** full_name that fetchRepoMetadata resolves to a 404, for webhook/resync failure-path tests. */ +export const NOT_FOUND_REPO_FULL_NAME = 'fixture-org/does-not-exist'; + +function installationIdFromAuth(request: Request): number | null { + const auth = request.headers.get('Authorization'); + const match = auth?.match(/^Bearer mock-token-(\d+)$/); + return match ? Number(match[1]) : null; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +/** + * `oidcJwk` is passed in (rather than read via crypto.ts's inject()-based + * jwksFor()) because this function is invoked from vitest.config.mts's plugin + * options callback, a different call context than test files — only the + * `ctx.inject` reference handed to that callback is guaranteed to resolve + * provide()d values there. + */ +export async function githubOutboundService(request: Request, oidcJwk: JWK): Promise { + const url = new URL(request.url); + + if (url.href === OIDC_JWKS_URL) { + return json({ keys: [{ ...oidcJwk, kid: OIDC_KID, alg: 'RS256', use: 'sig' }] }); + } + + let match: RegExpMatchArray | null; + + match = url.pathname.match(/^\/app\/installations\/(\d+)\/access_tokens$/); + if (match && request.method === 'POST') { + const id = Number(match[1]); + if (id === FAILING_ACCESS_TOKEN_INSTALLATION_ID) return json({ message: 'Server error' }, 500); + return json({ token: `mock-token-${id}`, expires_at: new Date(Date.now() + 600_000).toISOString() }); + } + + match = url.pathname.match(/^\/app\/installations\/(\d+)$/); + if (match && request.method === 'GET') { + const id = Number(match[1]); + if (id === MISSING_INSTALLATION_ID) return json({ message: 'Not Found' }, 404); + const fixture = FIXTURE_INSTALLATIONS[id]; + if (!fixture) return json({ message: 'Not Found' }, 404); + return json({ id, account: fixture.account }); + } + + if (url.pathname === '/installation/repositories' && request.method === 'GET') { + const id = installationIdFromAuth(request); + const fixture = id != null ? FIXTURE_INSTALLATIONS[id] : undefined; + if (!fixture) return json({ message: 'Not Found' }, 404); + + const perPage = Number(url.searchParams.get('per_page') ?? '100'); + const page = Number(url.searchParams.get('page') ?? '1'); + const start = (page - 1) * perPage; + return json({ + repositories: fixture.repos.slice(start, start + perPage), + total_count: fixture.repos.length, + }); + } + + match = url.pathname.match(/^\/repos\/(.+)$/); + if (match && request.method === 'GET') { + const fullName = decodeURIComponent(match[1]); + if (fullName === NOT_FOUND_REPO_FULL_NAME) return json({ message: 'Not Found' }, 404); + const name = fullName.split('/')[1] ?? fullName; + // Convention for tests that need a non-'main' default branch: name ends with '-devbranch'. + const default_branch = name.endsWith('-devbranch') ? 'develop' : 'main'; + return json({ id: 9_000_000, name, full_name: fullName, default_branch }); + } + + console.error(`Unhandled outbound fetch in tests: ${request.method} ${request.url}`); + return json({ error: 'unhandled outbound request in test fixture' }, 404); +} diff --git a/test/lib/github.test.ts b/test/lib/github.test.ts new file mode 100644 index 0000000..6397f6f --- /dev/null +++ b/test/lib/github.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { getInstallationToken, fetchInstallationRepos, fetchRepoMetadata } from '../../src/lib/github'; +import { getTestAppPrivateKeyPem } from '../helpers/crypto'; +import { FAILING_ACCESS_TOKEN_INSTALLATION_ID, NOT_FOUND_REPO_FULL_NAME } from '../helpers/outbound'; + +describe('getInstallationToken', () => { + it('mints a token for a valid installation', async () => { + const { token, expiresAt } = await getInstallationToken('test-app-id', getTestAppPrivateKeyPem(), 100); + expect(token).toBe('mock-token-100'); + expect(new Date(expiresAt).getTime()).toBeGreaterThan(Date.now()); + }); + + it('throws when GitHub fails to mint the token', async () => { + await expect( + getInstallationToken('test-app-id', getTestAppPrivateKeyPem(), FAILING_ACCESS_TOKEN_INSTALLATION_ID), + ).rejects.toThrow(/Failed to mint installation token/); + }); +}); + +describe('fetchInstallationRepos', () => { + it('returns all repos across a single page', async () => { + const repos = await fetchInstallationRepos('mock-token-100'); + expect(repos).toHaveLength(2); + expect(repos.map((r) => r.full_name)).toEqual(['fixture-org/repo-a', 'fixture-org/repo-b']); + }); + + it('follows pagination across multiple pages', async () => { + const repos = await fetchInstallationRepos('mock-token-101'); + expect(repos).toHaveLength(150); + // Unique ids across both pages — no duplication or truncation at the page boundary. + expect(new Set(repos.map((r) => r.id)).size).toBe(150); + }); + + it('throws when the installation repos request fails', async () => { + await expect(fetchInstallationRepos('mock-token-nonexistent')).rejects.toThrow( + /Failed to fetch installation repos/, + ); + }); +}); + +describe('fetchRepoMetadata', () => { + it('resolves default_branch for a repo', async () => { + const meta = await fetchRepoMetadata('mock-token-100', 'fixture-org/repo-a'); + expect(meta.full_name).toBe('fixture-org/repo-a'); + expect(meta.default_branch).toBe('main'); + }); + + it('resolves a develop default branch by the -devbranch naming convention', async () => { + const meta = await fetchRepoMetadata('mock-token-100', 'fixture-org/repo-devbranch'); + expect(meta.default_branch).toBe('develop'); + }); + + it('throws when the repo is not found', async () => { + await expect(fetchRepoMetadata('mock-token-100', NOT_FOUND_REPO_FULL_NAME)).rejects.toThrow( + /Failed to fetch repo metadata/, + ); + }); +}); diff --git a/test/lib/resync.test.ts b/test/lib/resync.test.ts new file mode 100644 index 0000000..5c93d1a --- /dev/null +++ b/test/lib/resync.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import { performResync } from '../../src/lib/resync'; +import { getTestAppPrivateKeyPem } from '../helpers/crypto'; +import { FAILING_ACCESS_TOKEN_INSTALLATION_ID } from '../helpers/outbound'; +import type { Bindings } from '../../src/types'; + +// @ts-expect-error cloudflare:test injects env at runtime +const testEnv = env as Bindings; + +beforeEach(async () => { + testEnv.GITHUB_APP_ID = 'test-app-id'; + testEnv.GITHUB_APP_PRIVATE_KEY = getTestAppPrivateKeyPem(); + // Projects/owners persist across `it`s within a file (no per-test D1 reset), so + // without clearing here, later tests' seeded "stale" rows would collide on the + // UNIQUE keys created by an earlier test's performResync() and be silently + // ignored by INSERT OR IGNORE, making assertions pass without exercising the + // path they claim to. + await testEnv.DB.prepare('DELETE FROM projects').run(); + await testEnv.DB.prepare('DELETE FROM owners').run(); +}); + +describe('performResync', () => { + it('adds repos that are missing locally', async () => { + const owner = await testEnv.DB.prepare('SELECT * FROM owners WHERE github_id = 100').first(); + expect(owner).toBeNull(); // sanity: nothing seeded yet for this installation + + await performResync(100, testEnv); + + const repoA = await testEnv.DB.prepare('SELECT * FROM projects WHERE github_repo_id = 1001').first<{ + full_slug: string; + default_branch: string; + installation_id: number; + }>(); + expect(repoA?.full_slug).toBe('fixture-org/repo-a'); + expect(repoA?.default_branch).toBe('main'); + expect(repoA?.installation_id).toBe(100); + + const repoB = await testEnv.DB.prepare('SELECT * FROM projects WHERE github_repo_id = 1002').first<{ + default_branch: string; + }>(); + expect(repoB?.default_branch).toBe('develop'); + }); + + it('updates metadata for repos that already exist locally', async () => { + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO owners (id, github_id, login, type) VALUES (910, 100, 'stale-login', 'Organization')`, + ).run(); + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO projects (owner_id, github_repo_id, repo_name, full_slug, installation_id, default_branch) + VALUES (910, 1001, 'stale-name', 'stale-org/stale-name', 100, 'stale-branch')`, + ).run(); + + await performResync(100, testEnv); + + const repoA = await testEnv.DB.prepare('SELECT * FROM projects WHERE github_repo_id = 1001').first<{ + full_slug: string; + default_branch: string; + }>(); + expect(repoA?.full_slug).toBe('fixture-org/repo-a'); + expect(repoA?.default_branch).toBe('main'); + + const owner = await testEnv.DB.prepare('SELECT login FROM owners WHERE github_id = 100').first<{ + login: string; + }>(); + expect(owner?.login).toBe('fixture-org'); + }); + + it('removes local projects that GitHub no longer reports for the installation', async () => { + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO owners (id, github_id, login, type) VALUES (911, 102, 'empty-org', 'Organization')`, + ).run(); + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO projects (owner_id, github_repo_id, repo_name, full_slug, installation_id, default_branch) + VALUES (911, 88888, 'orphan', 'empty-org/orphan', 102, 'main')`, + ).run(); + + await performResync(102, testEnv); + + const orphan = await testEnv.DB.prepare('SELECT * FROM projects WHERE github_repo_id = 88888').first(); + expect(orphan).toBeNull(); + }); + + it('throws when the GitHub API call fails', async () => { + await expect(performResync(FAILING_ACCESS_TOKEN_INSTALLATION_ID, testEnv)).rejects.toThrow(); + }); +}); diff --git a/test/middleware/oidc.test.ts b/test/middleware/oidc.test.ts new file mode 100644 index 0000000..f943fe8 --- /dev/null +++ b/test/middleware/oidc.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { env } from 'cloudflare:test'; +import { verifyOidcToken } from '../../src/middleware/oidc'; +import worker from '../../src/index'; +import { signOidcJwt, seedOidcJwks } from '../helpers/crypto'; +import type { Bindings } from '../../src/types'; + +// @ts-expect-error cloudflare:test injects env at runtime +const testEnv = env as Bindings; + +describe('verifyOidcToken', () => { + it('verifies a validly signed token against the cache-seeded JWKS', async () => { + await seedOidcJwks(); + const token = await signOidcJwt({ repository: 'testorg/repo' }); + const claims = await verifyOidcToken(token); + expect(claims.repository).toBe('testorg/repo'); + expect(claims.ref_type).toBe('branch'); + }); + + it('forces exactly one JWKS refetch on an unknown kid, then succeeds', async () => { + // Cache holds a JWKS under a kid that won't match the signed token, forcing + // fetchJWKS(true) — which the test outboundService answers with the real key. + await seedOidcJwks('stale-kid'); + const token = await signOidcJwt({ repository: 'testorg/repo2' }); + const claims = await verifyOidcToken(token); + expect(claims.repository).toBe('testorg/repo2'); + }); + + it('rejects when the kid is still unknown after the forced refetch', async () => { + await seedOidcJwks('stale-kid'); + const token = await signOidcJwt({}, { kid: 'totally-unknown-kid' }); + await expect(verifyOidcToken(token)).rejects.toThrow(/Unknown signing key/); + }); + + it('rejects a token with the wrong issuer', async () => { + await seedOidcJwks(); + const token = await signOidcJwt({ iss: 'https://not-github.example.com' }); + await expect(verifyOidcToken(token)).rejects.toThrow(); + }); + + it('rejects a token with the wrong audience', async () => { + await seedOidcJwks(); + const token = await signOidcJwt({ aud: 'someone-elses-app' }); + await expect(verifyOidcToken(token)).rejects.toThrow(); + }); + + it('rejects a non-RS256 token before any JWKS lookup', async () => { + const toBase64Url = (s: string) => btoa(s).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); + const fakeJwt = [ + toBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })), + toBase64Url(JSON.stringify({ sub: 'test' })), + 'fakesig', + ].join('.'); + await expect(verifyOidcToken(fakeJwt)).rejects.toThrow(/Unexpected JWT algorithm/); + }); +}); + +describe('requireOidc middleware', () => { + it('returns 401 for a syntactically invalid bearer token, without setting claims', async () => { + const res = await worker.fetch( + new Request('http://localhost/api/ci/coverage', { + method: 'POST', + headers: { Authorization: 'Bearer not-a-real-jwt', 'Content-Type': 'application/json' }, + body: JSON.stringify({ line_coverage: 90 }), + }), + testEnv as never, + ); + expect(res.status).toBe(401); + }); +}); diff --git a/test/middleware/webhook.test.ts b/test/middleware/webhook.test.ts new file mode 100644 index 0000000..95cf9c5 --- /dev/null +++ b/test/middleware/webhook.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import worker from '../../src/index'; +import { signWebhookBody } from '../helpers/crypto'; +import type { Bindings } from '../../src/types'; + +// @ts-expect-error cloudflare:test injects env at runtime +const testEnv = env as Bindings; + +// .dev.vars ships GITHUB_WEBHOOK_SECRET blank; crypto.subtle.importKey rejects a +// zero-length HMAC key, so tests need a real value. +beforeEach(() => { + testEnv.GITHUB_WEBHOOK_SECRET = 'test-webhook-secret'; +}); + +const BODY = JSON.stringify({ action: 'ping' }); + +async function postWebhook(opts: { + body?: string; + signature?: string | null; + deliveryId?: string | null; + event?: string; +}): Promise { + const body = opts.body ?? BODY; + const headers: Record = { 'Content-Type': 'application/json' }; + if (opts.event !== undefined) headers['X-GitHub-Event'] = opts.event; + if (opts.deliveryId !== null) headers['X-GitHub-Delivery'] = opts.deliveryId ?? 'delivery-1'; + if (opts.signature !== null) { + headers['X-Hub-Signature-256'] = opts.signature ?? (await signWebhookBody(testEnv.GITHUB_WEBHOOK_SECRET, body)); + } + return worker.fetch( + new Request('http://localhost/api/webhooks/github', { method: 'POST', headers, body }), + testEnv as never, + ); +} + +describe('requireWebhookHmac', () => { + it('returns 400 when X-Hub-Signature-256 is missing', async () => { + const res = await postWebhook({ signature: null, deliveryId: 'd-missing-sig' }); + expect(res.status).toBe(400); + }); + + it('returns 400 when X-GitHub-Delivery is missing', async () => { + const res = await postWebhook({ deliveryId: null }); + expect(res.status).toBe(400); + }); + + it('returns 400 when the signature is missing the sha256= prefix', async () => { + const res = await postWebhook({ signature: 'deadbeef', deliveryId: 'd-bad-prefix' }); + expect(res.status).toBe(400); + }); + + it('returns 401 when the signature does not match', async () => { + const res = await postWebhook({ + signature: `sha256=${'0'.repeat(64)}`, + deliveryId: 'd-bad-sig', + }); + expect(res.status).toBe(401); + }); + + it('passes through on a valid signature and records the delivery id', async () => { + const res = await postWebhook({ deliveryId: 'd-valid-1' }); + expect(res.status).toBe(200); + + const row = await testEnv.DB.prepare('SELECT delivery_id FROM webhook_deliveries WHERE delivery_id = ?') + .bind('d-valid-1') + .first(); + expect(row).not.toBeNull(); + }); + + it('rejects a replayed delivery id with 409', async () => { + const first = await postWebhook({ deliveryId: 'd-replay' }); + expect(first.status).toBe(200); + + const replay = await postWebhook({ deliveryId: 'd-replay' }); + expect(replay.status).toBe(409); + }); +}); diff --git a/test/scheduled.test.ts b/test/scheduled.test.ts new file mode 100644 index 0000000..c095cc9 --- /dev/null +++ b/test/scheduled.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { env, createExecutionContext, waitOnExecutionContext, createScheduledController } from 'cloudflare:test'; +import worker from '../src/index'; +import type { Bindings } from '../src/types'; + +// @ts-expect-error cloudflare:test injects env at runtime +const testEnv = env as Bindings; + +beforeEach(async () => { + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO owners (id, github_id, login, type) VALUES (1, 1, 'testorg', 'Organization')`, + ).run(); + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO projects (id, owner_id, github_repo_id, repo_name, full_slug, installation_id, default_branch) + VALUES (1, 1, 1, 'repo', 'testorg/repo', 1, 'main')`, + ).run(); + await testEnv.DB.prepare('DELETE FROM coverage_daily').run(); + await testEnv.DB.prepare('DELETE FROM coverage_runs').run(); +}); + +describe('scheduled() cron entry point', () => { + it('runs rollupAndPrune via ctx.waitUntil and prunes old runs into coverage_daily', async () => { + const now = Math.floor(Date.now() / 1000); + const old = now - 15 * 86400; // beyond the 14-day retention window + + await testEnv.DB.prepare( + `INSERT INTO coverage_runs (project_id, commit_sha, branch, ran_at, line_coverage) + VALUES (1, 'sha-old', 'main', ?1, 82.0), + (1, 'sha-new', 'main', ?2, 91.0)`, + ).bind(old, now).run(); + + const ctx = createExecutionContext(); + const controller = createScheduledController(); + // index.ts's scheduled() handler never reads its first argument — it exists + // only to satisfy the ScheduledExportedHandler signature. + await worker.scheduled(controller as never, testEnv, ctx); + // scheduled() fires ctx.waitUntil(rollupAndPrune(env)) as a background promise; + // without this, D1 assertions below would race that unawaited promise. + await waitOnExecutionContext(ctx); + + const remaining = await testEnv.DB.prepare('SELECT commit_sha FROM coverage_runs').all<{ commit_sha: string }>(); + expect(remaining.results.map((r) => r.commit_sha)).toEqual(['sha-new']); + + const daily = await testEnv.DB.prepare( + 'SELECT line_coverage FROM coverage_daily WHERE project_id = 1', + ).first<{ line_coverage: number }>(); + expect(daily?.line_coverage).toBe(82.0); + }); +}); diff --git a/test/webhooks.test.ts b/test/webhooks.test.ts new file mode 100644 index 0000000..5db7c29 --- /dev/null +++ b/test/webhooks.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import worker from '../src/index'; +import { signWebhookBody, getTestAppPrivateKeyPem } from './helpers/crypto'; +import { FAILING_ACCESS_TOKEN_INSTALLATION_ID } from './helpers/outbound'; +import type { Bindings } from '../src/types'; + +// @ts-expect-error cloudflare:test injects env at runtime +const testEnv = env as Bindings; + +beforeEach(() => { + testEnv.GITHUB_WEBHOOK_SECRET = 'test-webhook-secret'; + testEnv.GITHUB_APP_ID = 'test-app-id'; + testEnv.GITHUB_APP_PRIVATE_KEY = getTestAppPrivateKeyPem(); +}); + +async function postWebhookEvent(event: string, payload: unknown, deliveryId: string): Promise { + const body = JSON.stringify(payload); + const signature = await signWebhookBody(testEnv.GITHUB_WEBHOOK_SECRET, body); + return worker.fetch( + new Request('http://localhost/api/webhooks/github', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-GitHub-Event': event, + 'X-GitHub-Delivery': deliveryId, + 'X-Hub-Signature-256': signature, + }, + body, + }), + testEnv as never, + ); +} + +async function getOwnerByGithubId(githubId: number) { + return testEnv.DB.prepare('SELECT * FROM owners WHERE github_id = ?').bind(githubId).first(); +} + +async function getProjectByRepoId(githubRepoId: number) { + return testEnv.DB.prepare('SELECT * FROM projects WHERE github_repo_id = ?').bind(githubRepoId).first<{ + full_slug: string; + default_branch: string; + installation_id: number; + }>(); +} + +describe('POST /api/webhooks/github', () => { + it('installation created: upserts the owner and each repo with resolved default_branch', async () => { + const res = await postWebhookEvent( + 'installation', + { + action: 'created', + installation: { + id: 42, + account: { id: 555, login: 'neworg', type: 'Organization', avatar_url: 'https://example.com/x.png' }, + }, + repositories: [ + { id: 7001, name: 'alpha', full_name: 'neworg/alpha' }, + { id: 7002, name: 'beta-devbranch', full_name: 'neworg/beta-devbranch' }, + ], + }, + 'd-install-created', + ); + expect(res.status).toBe(200); + + const owner = await getOwnerByGithubId(555); + expect(owner).not.toBeNull(); + + const alpha = await getProjectByRepoId(7001); + expect(alpha?.full_slug).toBe('neworg/alpha'); + expect(alpha?.default_branch).toBe('main'); + expect(alpha?.installation_id).toBe(42); + + const beta = await getProjectByRepoId(7002); + expect(beta?.default_branch).toBe('develop'); + }); + + it('installation deleted: removes all projects for that installation', async () => { + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO owners (id, github_id, login, type) VALUES (900, 900, 'delorg', 'Organization')`, + ).run(); + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO projects (owner_id, github_repo_id, repo_name, full_slug, installation_id, default_branch) + VALUES (900, 9001, 'gone', 'delorg/gone', 77, 'main')`, + ).run(); + + const res = await postWebhookEvent( + 'installation', + { action: 'deleted', installation: { id: 77 } }, + 'd-install-deleted', + ); + expect(res.status).toBe(200); + + const project = await getProjectByRepoId(9001); + expect(project).toBeNull(); + }); + + it('installation_repositories added: upserts newly added repos', async () => { + const res = await postWebhookEvent( + 'installation_repositories', + { + action: 'added', + installation: { + id: 43, + account: { id: 556, login: 'addorg', type: 'User', avatar_url: 'https://example.com/y.png' }, + }, + repositories_added: [{ id: 7101, name: 'gamma', full_name: 'addorg/gamma' }], + }, + 'd-repos-added', + ); + expect(res.status).toBe(200); + + const project = await getProjectByRepoId(7101); + expect(project?.full_slug).toBe('addorg/gamma'); + }); + + it('installation_repositories removed: deletes the removed repos', async () => { + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO owners (id, github_id, login, type) VALUES (901, 901, 'remorg', 'Organization')`, + ).run(); + await testEnv.DB.prepare( + `INSERT OR IGNORE INTO projects (owner_id, github_repo_id, repo_name, full_slug, installation_id, default_branch) + VALUES (901, 8001, 'leaving', 'remorg/leaving', 44, 'main')`, + ).run(); + + const res = await postWebhookEvent( + 'installation_repositories', + { + action: 'removed', + installation: { id: 44, account: { id: 901, login: 'remorg', type: 'Organization', avatar_url: '' } }, + repositories_removed: [{ id: 8001, name: 'leaving', full_name: 'remorg/leaving' }], + }, + 'd-repos-removed', + ); + expect(res.status).toBe(200); + + const project = await getProjectByRepoId(8001); + expect(project).toBeNull(); + }); + + it('ignores an unexpected account.type without writing any rows', async () => { + const res = await postWebhookEvent( + 'installation', + { + action: 'created', + installation: { + id: 45, + account: { id: 557, login: 'bot-account', type: 'Bot', avatar_url: '' }, + }, + repositories: [{ id: 7201, name: 'ignored', full_name: 'bot-account/ignored' }], + }, + 'd-bad-account-type', + ); + expect(res.status).toBe(200); + + const owner = await getOwnerByGithubId(557); + expect(owner).toBeNull(); + const project = await getProjectByRepoId(7201); + expect(project).toBeNull(); + }); + + it('acknowledges an unrecognized event with 200 and no side effects', async () => { + const res = await postWebhookEvent('ping', { zen: 'test' }, 'd-ping'); + expect(res.status).toBe(200); + const body = await res.json() as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + it('returns 200 even when the downstream handler throws', async () => { + const res = await postWebhookEvent( + 'installation', + { + action: 'created', + installation: { + id: FAILING_ACCESS_TOKEN_INSTALLATION_ID, + account: { id: 558, login: 'failorg', type: 'Organization', avatar_url: '' }, + }, + repositories: [{ id: 7301, name: 'never-created', full_name: 'failorg/never-created' }], + }, + 'd-handler-throws', + ); + expect(res.status).toBe(200); + + const project = await getProjectByRepoId(7301); + expect(project).toBeNull(); + }); + + it('rejects a replayed delivery id with 409', async () => { + const payload = { action: 'created', installation: { id: 46, account: { id: 559, login: 'x', type: 'User', avatar_url: '' } }, repositories: [] }; + const first = await postWebhookEvent('installation', payload, 'd-replay-wh'); + expect(first.status).toBe(200); + + const replay = await postWebhookEvent('installation', payload, 'd-replay-wh'); + expect(replay.status).toBe(409); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 6fd4efb..32fe8d4 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,15 +1,37 @@ import { defineConfig } from 'vitest/config'; import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; +import { githubOutboundService } from './test/helpers/outbound'; export default defineConfig({ plugins: [ - cloudflareTest({ - wrangler: { configPath: './wrangler.json' }, + cloudflareTest(async ({ inject }) => { + // Generated once in test/global-setup.ts and read here via the injected + // `inject`, not the plain `vitest` import — only this reference is + // guaranteed to resolve provide()d values from the plugin-options callback. + const oidcJwk = inject('oidcTestPublicJwk'); + return { + wrangler: { configPath: './wrangler.json' }, + miniflare: { + // fetchMock isn't exported by cloudflare:test in this pool version, so GitHub + // API + JWKS calls made by the worker under test are intercepted here instead. + outboundService: (request: Request) => githubOutboundService(request, oidcJwk), + }, + }; }), ], test: { globalSetup: ['./test/global-setup.ts'], setupFiles: ['./test/migrate.ts'], include: ['test/**/*.test.ts'], + coverage: { + // v8 native coverage is unsupported under workerd; the Workers pool + // requires the instrumented Istanbul provider. + provider: 'istanbul', + // 'lcov' emits coverage/lcov.info — the zero-config probe path the + // report Action auto-detects. + reporter: ['lcov', 'text', 'json-summary'], + reportsDirectory: 'coverage', + include: ['src/**'], + }, }, });