From 45a4f7508c0d2d0195ece1329c0f9fe6a594cf87 Mon Sep 17 00:00:00 2001 From: QA Runner Date: Mon, 20 Jul 2026 11:00:01 -0700 Subject: [PATCH 1/4] test: route-level integration tests + app/index split Ports the backend route-level integration suites from the amal66 fork (index: Open-Legal-Products/mike#205), adapted from the fork's apps/api modules/services layout to this repo's monolithic backend/src/routes/*.ts layout. app/index split (the only production change; mechanical, zero behavior change): backend/src/index.ts previously built the express app and called app.listen at the bottom. Everything except the listen call moved verbatim into backend/src/app.ts, which now exports `app` (same middleware order, same routes, same rate-limiter setup, dotenv/config still imported first). index.ts is now a tiny entry that imports { app } and calls listen with the same log line. `npm run build` still emits dist/index.js and the `start` script is unchanged. New suites under backend/src/__tests__/integration/ (94 tests): - health.test.ts (5): /health, requireAuth 401 paths, 404 fallthrough - chat.routes.test.ts (6): POST /chat validation + SSE happy/error paths - projects.routes.test.ts (18): overview/create/detail/patch/delete, sharing normalisation, ownership guards - projectChat.routes.test.ts (3): project access guard + SSE paths - tabular.routes.test.ts (31): review CRUD, access guards, document-access filtering, missing_api_key guards - user.routes.test.ts (27): profile, API-key crypto boundary, MFA guards, export/deletion endpoints - documentsUpload.routes.test.ts (4): upload validation + download-zip bounds/access - access.supabase.test.ts (1) + stack.supabase.test.ts (4): gated on SUPABASE_TEST_URL / SUPABASE_TEST_SERVICE_ROLE_KEY (stack suite also needs SUPABASE_TEST_ANON_KEY); describe.skip otherwise - scripts/test-stack.sh + `npm run test:stack`: reads a running `supabase status -o json` and runs the gated suites Adds supertest + @types/supertest as devDependencies. Dropped relative to the fork (subjects that do not exist in this repo): - orgs.routes, credits.concurrency.supabase, dmsConnectors suites (fork-only features) - all credit-reservation cases (429 CREDIT_LIMIT_EXCEEDED, reserve-then-refund) in chat/projectChat: no credit system here - health /ready case: no /ready endpoint here - org-membership project access case: no org model here - upload magic-byte validation cases: this repo validates extension only (the fork adds content sniffing; replaced with a missing-file 400 case) - download-zip 50-document cap case: no cap here (replaced with a no-accessible-documents 404 case) - stack.supabase PUBLIC_TABLES updated to this repo's schema; the fork's credit-RPC coverage is n/a Verified locally: backend `npm test` -> 8 files passed, 2 skipped; 106 tests passed, 5 skipped (gated suites skip without env). `npm run build` passes. The gated suites were additionally run against a live local Supabase stack: 2 files, 5/5 tests passed. Co-Authored-By: Claude Fable 5 --- backend/package-lock.json | 292 ++++++- backend/package.json | 5 +- backend/scripts/test-stack.sh | 38 + .../integration/access.supabase.test.ts | 84 +++ .../__tests__/integration/chat.routes.test.ts | 173 +++++ .../documentsUpload.routes.test.ts | 108 +++ .../src/__tests__/integration/health.test.ts | 80 ++ .../integration/projectChat.routes.test.ts | 149 ++++ .../integration/projects.routes.test.ts | 415 ++++++++++ .../integration/stack.supabase.test.ts | 146 ++++ .../integration/tabular.routes.test.ts | 713 ++++++++++++++++++ .../__tests__/integration/user.routes.test.ts | 588 +++++++++++++++ backend/src/app.ts | 161 ++++ backend/src/index.ts | 161 +--- 14 files changed, 2949 insertions(+), 164 deletions(-) create mode 100755 backend/scripts/test-stack.sh create mode 100644 backend/src/__tests__/integration/access.supabase.test.ts create mode 100644 backend/src/__tests__/integration/chat.routes.test.ts create mode 100644 backend/src/__tests__/integration/documentsUpload.routes.test.ts create mode 100644 backend/src/__tests__/integration/health.test.ts create mode 100644 backend/src/__tests__/integration/projectChat.routes.test.ts create mode 100644 backend/src/__tests__/integration/projects.routes.test.ts create mode 100644 backend/src/__tests__/integration/stack.supabase.test.ts create mode 100644 backend/src/__tests__/integration/tabular.routes.test.ts create mode 100644 backend/src/__tests__/integration/user.routes.test.ts create mode 100644 backend/src/app.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 65b2c63..fde72bd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -37,7 +37,9 @@ "@types/express": "^4.17.21", "@types/multer": "^1.4.12", "@types/node": "^22.14.1", + "@types/supertest": "^7.2.1", "prettier": "^3.8.1", + "supertest": "^7.2.2", "tsx": "^4.19.3", "typescript": "^5.8.3", "vitest": "^4.1.9" @@ -2121,6 +2123,19 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -2143,6 +2158,16 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -3357,6 +3382,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -3414,6 +3446,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3493,6 +3532,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -3706,6 +3769,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3722,6 +3792,13 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3858,6 +3935,29 @@ "node": ">=18" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-stream": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -3916,6 +4016,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -3980,6 +4087,16 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4009,6 +4126,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dingbat-to-unicode": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", @@ -4210,6 +4338,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -4390,6 +4534,13 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "license": "Apache-2.0" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -4501,6 +4652,23 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -4513,6 +4681,24 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4683,6 +4869,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -4694,9 +4896,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6293,6 +6495,90 @@ ], "license": "MIT" }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/backend/package.json b/backend/package.json index d540717..5a91f70 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,8 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "test": "vitest run" + "test": "vitest run", + "test:stack": "bash scripts/test-stack.sh" }, "dependencies": { "@anthropic-ai/sdk": "^0.90.0", @@ -37,7 +38,9 @@ "@types/express": "^4.17.21", "@types/multer": "^1.4.12", "@types/node": "^22.14.1", + "@types/supertest": "^7.2.1", "prettier": "^3.8.1", + "supertest": "^7.2.2", "tsx": "^4.19.3", "typescript": "^5.8.3", "vitest": "^4.1.9" diff --git a/backend/scripts/test-stack.sh b/backend/scripts/test-stack.sh new file mode 100755 index 0000000..2f647a7 --- /dev/null +++ b/backend/scripts/test-stack.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Run the gated stack-level integration tests against a local Supabase stack. +# +# These tests exercise the REAL stack (GoTrue auth + Postgres RLS) instead of +# mocks. They are the harness you re-run on every Supabase image bump to prove +# the auth↔API contract and the deny-all RLS firewall still hold. +# +# Usage: supabase start # in the repo, once +# npm run test:stack (from backend/) +set -euo pipefail + +if ! command -v supabase >/dev/null 2>&1; then + echo "supabase CLI not found. Install: brew install supabase/tap/supabase" >&2 + exit 1 +fi + +STATUS="$(supabase status -o json 2>/dev/null)" || { + echo "No running Supabase stack. Start one with: supabase start" >&2 + exit 1 +} + +read_key() { node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(String(JSON.parse(s)['$1']??'')))" <<<"$STATUS"; } + +SUPABASE_TEST_URL="$(read_key API_URL)" +SUPABASE_TEST_SERVICE_ROLE_KEY="$(read_key SERVICE_ROLE_KEY)" +SUPABASE_TEST_ANON_KEY="$(read_key ANON_KEY)" + +if [[ -z "$SUPABASE_TEST_URL" || -z "$SUPABASE_TEST_SERVICE_ROLE_KEY" || -z "$SUPABASE_TEST_ANON_KEY" ]]; then + echo "Could not read API_URL/SERVICE_ROLE_KEY/ANON_KEY from 'supabase status'." >&2 + exit 1 +fi +export SUPABASE_TEST_URL SUPABASE_TEST_SERVICE_ROLE_KEY SUPABASE_TEST_ANON_KEY + +echo "Running stack integration tests against $SUPABASE_TEST_URL" +exec npx vitest run \ + src/__tests__/integration/stack.supabase.test.ts \ + src/__tests__/integration/access.supabase.test.ts \ + "$@" diff --git a/backend/src/__tests__/integration/access.supabase.test.ts b/backend/src/__tests__/integration/access.supabase.test.ts new file mode 100644 index 0000000..65b9244 --- /dev/null +++ b/backend/src/__tests__/integration/access.supabase.test.ts @@ -0,0 +1,84 @@ +import { createClient } from "@supabase/supabase-js"; +import { describe, expect, it } from "vitest"; +import { + filterAccessibleDocumentIds, + listAccessibleProjectIds, +} from "../../lib/access"; + +// Gated: runs only against a real (local) Supabase stack. +// supabase start, then export: +// SUPABASE_TEST_URL, SUPABASE_TEST_SERVICE_ROLE_KEY +// or use scripts/test-stack.sh which reads them from `supabase status`. +const url = process.env.SUPABASE_TEST_URL; +const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY; + +const maybeDescribe = url && serviceKey ? describe : describe.skip; + +maybeDescribe("Supabase access integration", () => { + it("proves tabular document filtering drops foreign document IDs", async () => { + const admin = createClient(url!, serviceKey!, { + auth: { persistSession: false }, + }); + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const ownerId = crypto.randomUUID(); + const reviewerId = crypto.randomUUID(); + const sharedProjectId = crypto.randomUUID(); + const privateProjectId = crypto.randomUUID(); + const sharedDocId = crypto.randomUUID(); + const privateDocId = crypto.randomUUID(); + + try { + await admin.from("projects").insert([ + { + id: sharedProjectId, + user_id: ownerId, + name: `shared-${suffix}`, + shared_with: [`reviewer-${suffix}@example.com`], + }, + { + id: privateProjectId, + user_id: ownerId, + name: `private-${suffix}`, + shared_with: [], + }, + ]); + // filename/file_type live on document_versions in this schema — + // the documents rows only need identity + ownership columns. + await admin.from("documents").insert([ + { + id: sharedDocId, + user_id: ownerId, + project_id: sharedProjectId, + }, + { + id: privateDocId, + user_id: ownerId, + project_id: privateProjectId, + }, + ]); + + await expect( + listAccessibleProjectIds( + reviewerId, + `reviewer-${suffix}@example.com`, + admin as any, + ), + ).resolves.toContain(sharedProjectId); + + await expect( + filterAccessibleDocumentIds( + [sharedDocId, privateDocId], + reviewerId, + `reviewer-${suffix}@example.com`, + admin as any, + ), + ).resolves.toEqual([sharedDocId]); + } finally { + await admin.from("documents").delete().in("id", [sharedDocId, privateDocId]); + await admin + .from("projects") + .delete() + .in("id", [sharedProjectId, privateProjectId]); + } + }); +}); diff --git a/backend/src/__tests__/integration/chat.routes.test.ts b/backend/src/__tests__/integration/chat.routes.test.ts new file mode 100644 index 0000000..64e71ec --- /dev/null +++ b/backend/src/__tests__/integration/chat.routes.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +// Hoisted mock fn so the vi.mock factory below (which is itself hoisted above +// the imports) can reference it. Lets each test drive the stream outcome. +const { runLLMStream } = vi.hoisted(() => ({ + runLLMStream: vi.fn(), +})); + +// A permissive, chainable Supabase stub. Every query-builder method returns the +// same object (so arbitrary chains work), the object is awaitable (thenable), +// and the terminal single()/maybeSingle() resolve to a chat row. The chat +// routes only read `.id`/`.title` and check `.error`, so this is enough to let +// a request flow through chat creation and message inserts without real IO. +function makeQuery() { + const result = { data: { id: "chat-1", title: null }, error: null }; + const q: Record = {}; + const chain = [ + "select", "insert", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.single = vi.fn(() => Promise.resolve(result)); + q.maybeSingle = vi.fn(() => Promise.resolve(result)); + q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(result).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn(() => makeQuery()), + rpc: vi.fn(() => Promise.resolve({ data: null, error: null })), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +// Authenticate every request as user "u1" without exercising the real Supabase +// JWT path. requireMfaIfEnrolled must be exported too — userRouter (mounted by +// the app) imports it at module load. +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +// Keep the real error helpers (the failure-path test relies on genuine +// isAbortError + AssistantStreamError behavior) but stub the functions that +// would otherwise hit the DB or the LLM. +vi.mock("../../lib/chat", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildDocContext: vi.fn(async () => ({ docIndex: {}, docStore: new Map() })), + enrichWithPriorEvents: vi.fn(async (messages: unknown) => messages), + buildWorkflowStore: vi.fn(async () => new Map()), + buildMessages: vi.fn(() => []), + runLLMStream: (...args: unknown[]) => runLLMStream(...args), + }; +}); + +vi.mock("../../lib/userSettings", () => ({ + getUserModelSettings: vi.fn(async () => ({ + legal_research_us: false, + title_model: "test-model", + tabular_model: "test-model", + api_keys: {}, + })), + getUserApiKeys: vi.fn(async () => ({})), +})); + +import { app } from "../../app"; + +const VALID_BODY = { messages: [{ role: "user", content: "hello" }] }; + +describe("POST /chat — streaming endpoint", () => { + beforeEach(() => { + vi.clearAllMocks(); + runLLMStream.mockResolvedValue({ + fullText: "hi there", + events: [], + citations: [], + }); + }); + + it("streams SSE with a chat_id event on the happy path", async () => { + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/event-stream"); + expect(res.text).toContain('"type":"chat_id"'); + expect(runLLMStream).toHaveBeenCalledTimes(1); + }); + + it("surfaces a stream failure as an in-stream error event, not an HTTP error", async () => { + runLLMStream.mockRejectedValue(new Error("upstream LLM failure")); + + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + // Headers were already flushed (200) before the stream threw, so the + // failure surfaces as an in-stream error event + [DONE]. + expect(res.status).toBe(200); + expect(res.text).toContain('"type":"error"'); + expect(res.text).toContain("[DONE]"); + }); + + it("returns 400 on an empty messages array (never starts a stream)", async () => { + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send({ messages: [] }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("detail"); + expect(runLLMStream).not.toHaveBeenCalled(); + }); + + it("returns 400 when messages is missing entirely", async () => { + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send({}); + + expect(res.status).toBe(400); + expect(runLLMStream).not.toHaveBeenCalled(); + }); + + it("returns 400 when chat_id is not a non-empty string", async () => { + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send({ ...VALID_BODY, chat_id: " " }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("chat_id must be a non-empty string"); + expect(runLLMStream).not.toHaveBeenCalled(); + }); +}); + +describe("PATCH /chat/:chatId", () => { + it("returns 400 when title is missing", async () => { + const res = await request(app) + .patch("/chat/chat-1") + .set("Authorization", "Bearer test") + .send({}); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("title is required"); + }); +}); diff --git a/backend/src/__tests__/integration/documentsUpload.routes.test.ts b/backend/src/__tests__/integration/documentsUpload.routes.test.ts new file mode 100644 index 0000000..e7544cf --- /dev/null +++ b/backend/src/__tests__/integration/documentsUpload.routes.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest"; +import request from "supertest"; + +function mockSupabase() { + const result = { data: null, error: null }; + const q: Record = {}; + const chain = [ + "select", "insert", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "not", "lt", "order", "limit", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.single = vi.fn(() => Promise.resolve(result)); + q.maybeSingle = vi.fn(() => Promise.resolve(result)); + q.then = (resolve: (v: unknown) => unknown) => + Promise.resolve(result).then(resolve); + return { + from: vi.fn(() => q), + rpc: vi.fn(() => Promise.resolve(result)), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +// Stub the storage IO functions so a request that clears validation never +// touches R2/S3, while keeping the rest of the storage module (key builders, +// disposition helpers) real. The validation tests below reject before storage +// is reached, but this guards against accidental real IO regardless. +vi.mock("../../lib/storage", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + uploadFile: vi.fn(async () => {}), + downloadFile: vi.fn(async () => null), + deleteFile: vi.fn(async () => {}), + }; +}); + +import { app } from "../../app"; + +describe("POST /single-documents — upload validation", () => { + it("rejects an unsupported file extension with 400", async () => { + const res = await request(app) + .post("/single-documents") + .set("Authorization", "Bearer test") + .attach("file", Buffer.from("hello world"), { + filename: "notes.txt", + contentType: "text/plain", + }); + + expect(res.status).toBe(400); + expect(res.body.detail).toMatch(/unsupported file type/i); + }); + + it("rejects a request with no file attached with 400", async () => { + const res = await request(app) + .post("/single-documents") + .set("Authorization", "Bearer test") + .field("note", "no file here"); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("file is required"); + }); +}); + +describe("POST /single-documents/download-zip — bounds", () => { + it("returns 400 when document_ids is empty", async () => { + const res = await request(app) + .post("/single-documents/download-zip") + .set("Authorization", "Bearer test") + .send({ document_ids: [] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toMatch(/document_ids is required/i); + }); + + it("returns 404 when none of the requested documents are accessible", async () => { + // The documents lookup resolves to no rows (stubbed DB), so the + // access filter leaves nothing to zip. + const res = await request(app) + .post("/single-documents/download-zip") + .set("Authorization", "Bearer test") + .send({ document_ids: ["d-other-user"] }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("No documents found"); + }); +}); diff --git a/backend/src/__tests__/integration/health.test.ts b/backend/src/__tests__/integration/health.test.ts new file mode 100644 index 0000000..8a36172 --- /dev/null +++ b/backend/src/__tests__/integration/health.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from "vitest"; +import request from "supertest"; + +// requireAuth reads SUPABASE_URL / SUPABASE_SECRET_KEY from process.env at +// request time (not import time), so setting them here is early enough even +// though imported modules evaluate before this assignment runs. +process.env.SUPABASE_URL = "http://supabase.test.local"; +process.env.SUPABASE_SECRET_KEY = "test-service-key"; + +// Mock the supabase-js client factory so the real requireAuth middleware never +// makes a network call: auth.getUser() resolves to no user for any token, +// simulating an invalid/expired JWT. +vi.mock("@supabase/supabase-js", () => ({ + createClient: vi.fn(() => ({ + from: () => { + const q: Record = {}; + const chain = [ + "select", "insert", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "not", "filter", + "order", "limit", + ]; + for (const m of chain) q[m] = () => q; + q.single = () => Promise.resolve({ data: null, error: null }); + q.maybeSingle = () => Promise.resolve({ data: null, error: null }); + q.then = (resolve: (v: unknown) => unknown) => + Promise.resolve({ data: null, error: null }).then(resolve); + return q; + }, + rpc: () => Promise.resolve({ data: null, error: null }), + auth: { + getUser: () => + Promise.resolve({ data: { user: null }, error: null }), + }, + })), +})); + +// Vitest hoists vi.mock() calls before all imports, so this regular import +// receives the mocked supabase-js module even though it appears after the +// vi.mock() call in source order. +import { app } from "../../app"; + +describe("GET /health", () => { + it("returns 200 with { ok: true }", async () => { + const res = await request(app).get("/health"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); +}); + +describe("requireAuth middleware", () => { + it("rejects requests with no Authorization header (401)", async () => { + const res = await request(app).get("/chat"); + expect(res.status).toBe(401); + expect(res.body).toHaveProperty("detail"); + }); + + it("rejects requests with a non-Bearer Authorization header (401)", async () => { + const res = await request(app) + .get("/chat") + .set("Authorization", "Basic dXNlcjpwYXNz"); + expect(res.status).toBe(401); + }); + + it("rejects requests with an invalid Bearer token (401)", async () => { + // The mocked createClient().auth.getUser returns { user: null } for + // any token — simulating an expired/invalid token. + const res = await request(app) + .get("/chat") + .set("Authorization", "Bearer invalid-token"); + expect(res.status).toBe(401); + expect(res.body.detail).toMatch(/invalid|expired/i); + }); +}); + +describe("404 handling", () => { + it("returns 404 for unknown routes", async () => { + const res = await request(app).get("/this-route-does-not-exist"); + expect(res.status).toBe(404); + }); +}); diff --git a/backend/src/__tests__/integration/projectChat.routes.test.ts b/backend/src/__tests__/integration/projectChat.routes.test.ts new file mode 100644 index 0000000..8f6af78 --- /dev/null +++ b/backend/src/__tests__/integration/projectChat.routes.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +const { runLLMStream, checkProjectAccess } = vi.hoisted(() => ({ + runLLMStream: vi.fn(), + checkProjectAccess: vi.fn(), +})); + +function makeQuery() { + const result = { + data: { id: "chat-1", title: null, project_id: "p1" }, + error: null, + }; + const q: Record = {}; + const chain = [ + "select", "insert", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.single = vi.fn(() => Promise.resolve(result)); + q.maybeSingle = vi.fn(() => Promise.resolve(result)); + q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(result).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn(() => makeQuery()), + rpc: vi.fn(() => Promise.resolve({ data: null, error: null })), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +vi.mock("../../lib/chat", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildProjectDocContext: vi.fn(async () => ({ + docIndex: {}, + docStore: new Map(), + folderPaths: new Map(), + })), + enrichWithPriorEvents: vi.fn(async (messages: unknown) => messages), + buildWorkflowStore: vi.fn(async () => new Map()), + buildMessages: vi.fn(() => []), + runLLMStream: (...args: unknown[]) => runLLMStream(...args), + }; +}); + +vi.mock("../../lib/userSettings", () => ({ + getUserModelSettings: vi.fn(async () => ({ + legal_research_us: false, + title_model: "test-model", + tabular_model: "test-model", + api_keys: {}, + })), + getUserApiKeys: vi.fn(async () => ({})), +})); + +vi.mock("../../lib/access", () => ({ + checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args), + ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids), + listAccessibleProjectIds: vi.fn(async () => []), +})); + +import { app } from "../../app"; + +const VALID_BODY = { messages: [{ role: "user", content: "hello" }] }; + +describe("POST /projects/:projectId/chat", () => { + beforeEach(() => { + vi.clearAllMocks(); + runLLMStream.mockResolvedValue({ + fullText: "", + events: [], + citations: [], + }); + checkProjectAccess.mockResolvedValue({ + ok: true, + isOwner: true, + project: { id: "p1", user_id: "u1", shared_with: null }, + }); + }); + + it("returns 404 and never streams when project access is denied", async () => { + checkProjectAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/projects/p1/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + // The guard fires before any LLM stream. + expect(runLLMStream).not.toHaveBeenCalled(); + }); + + it("streams SSE on the happy path with project access granted", async () => { + const res = await request(app) + .post("/projects/p1/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/event-stream"); + expect(res.text).toContain('"type":"chat_id"'); + expect(runLLMStream).toHaveBeenCalledTimes(1); + }); + + it("surfaces a stream failure as an in-stream error event, not an HTTP error", async () => { + runLLMStream.mockRejectedValue(new Error("upstream LLM failure")); + + const res = await request(app) + .post("/projects/p1/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + expect(res.status).toBe(200); + expect(res.text).toContain('"type":"error"'); + expect(res.text).toContain("[DONE]"); + }); +}); diff --git a/backend/src/__tests__/integration/projects.routes.test.ts b/backend/src/__tests__/integration/projects.routes.test.ts new file mode 100644 index 0000000..408f24e --- /dev/null +++ b/backend/src/__tests__/integration/projects.routes.test.ts @@ -0,0 +1,415 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +// --------------------------------------------------------------------------- +// Hoisted mock fns we want to reconfigure per-test. +// --------------------------------------------------------------------------- +const { checkProjectAccess, deleteUserProjects } = vi.hoisted(() => ({ + checkProjectAccess: vi.fn(), + deleteUserProjects: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Configurable Supabase stub. Each test seeds `supabaseState` in beforeEach; +// terminal query operations (.single()/.maybeSingle()/thenable) resolve to the +// per-table result, and rpc() resolves to a per-call result. Insert payloads +// are recorded so tests can assert on normalisation (lowercasing / dedupe). +// --------------------------------------------------------------------------- +type QueryResult = { data: unknown; error: unknown }; + +let supabaseState: { + rpc: QueryResult; + tables: Record; + inserts: { table: string; payload: unknown }[]; +}; + +function resetSupabaseState() { + supabaseState = { + rpc: { data: [], error: null }, + tables: {}, + inserts: [], + }; +} +resetSupabaseState(); + +function resultForTable(table: string): QueryResult { + return supabaseState.tables[table] ?? { data: null, error: null }; +} + +function makeQuery(table: string) { + const q: Record = {}; + const chain = [ + "select", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.insert = vi.fn((payload: unknown) => { + supabaseState.inserts.push({ table, payload }); + return q; + }); + q.single = vi.fn(() => Promise.resolve(resultForTable(table))); + q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table))); + q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(resultForTable(table)).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn((table: string) => makeQuery(table)), + rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +// Every export of lib/access must be present — other routers (chat, documents, +// downloads, tabular) import from it at app load. +vi.mock("../../lib/access", () => ({ + checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args), + ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids), + listAccessibleProjectIds: vi.fn(async () => []), +})); + +// user router imports all four cleanup helpers at module load. +vi.mock("../../lib/userDataCleanup", () => ({ + deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args), + deleteAllUserChats: vi.fn(async () => {}), + deleteAllUserTabularReviews: vi.fn(async () => {}), + deleteUserAccountData: vi.fn(async () => {}), +})); + +// Version-path enrichment hits the DB in real life; no-op it so the route +// responses are driven purely by the documents/projects table stubs. +vi.mock("../../lib/documentVersions", () => ({ + attachActiveVersionPaths: vi.fn(async () => {}), + attachLatestVersionNumbers: vi.fn(async () => {}), + loadActiveVersion: vi.fn(async () => null), +})); + +import { app } from "../../app"; + +const AUTH = ["Authorization", "Bearer test"] as const; + +describe("projects.routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetSupabaseState(); + checkProjectAccess.mockResolvedValue({ + ok: true, + isOwner: true, + project: { id: "p1", user_id: "u1", shared_with: null }, + }); + deleteUserProjects.mockResolvedValue(1); + }); + + // ── GET /projects (overview) ────────────────────────────────────────── + describe("GET /projects", () => { + it("returns the overview rows from the RPC", async () => { + supabaseState.rpc = { + data: [{ id: "p1", name: "Alpha" }], + error: null, + }; + + const res = await request(app).get("/projects").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: "p1", name: "Alpha" }]); + }); + + it("returns 500 with detail when the RPC errors", async () => { + supabaseState.rpc = { data: null, error: { message: "boom" } }; + + const res = await request(app).get("/projects").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("boom"); + }); + }); + + // ── POST /projects (create) ─────────────────────────────────────────── + describe("POST /projects", () => { + it("returns 400 when name is missing/blank", async () => { + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ name: " " }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("name is required"); + }); + + it("returns 400 when sharing the project with yourself", async () => { + // The authed user's email is u1@test.local; supplying it (in any + // case) must be rejected. + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ name: "Beta", shared_with: ["U1@Test.Local"] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "You cannot share a project with yourself.", + ); + }); + + it("creates the project (201) and normalises shared_with", async () => { + // Sharing requires each recipient to have a mirrored user_profiles + // row (findMissingUserEmails); seed both emails so validation + // passes and the create path proceeds. + supabaseState.tables.user_profiles = { + data: [{ email: "a@x.com" }, { email: "b@x.com" }], + error: null, + }; + supabaseState.tables.projects = { + data: { + id: "p9", + name: "Gamma", + user_id: "u1", + shared_with: ["a@x.com", "b@x.com"], + }, + error: null, + }; + + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ + name: " Gamma ", + shared_with: ["A@x.com", "a@x.com", "B@X.com", "", " "], + }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ id: "p9", documents: [] }); + + // The insert payload should be lowercased, deduped, trimmed and + // the name trimmed. + const insert = supabaseState.inserts.find( + (i) => i.table === "projects", + ); + expect(insert?.payload).toMatchObject({ + name: "Gamma", + shared_with: ["a@x.com", "b@x.com"], + }); + }); + + it("returns 400 when a shared_with recipient is not a Mike user", async () => { + // No user_profiles rows seeded → findMissingUserEmails reports the + // recipient as unknown and the create is rejected before insert. + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ name: "Gamma", shared_with: ["ghost@x.com"] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "ghost@x.com does not belong to a Mike user.", + ); + expect( + supabaseState.inserts.find((i) => i.table === "projects"), + ).toBeUndefined(); + }); + + it("returns 500 when the insert errors", async () => { + supabaseState.tables.projects = { + data: null, + error: { message: "insert failed" }, + }; + + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ name: "Delta" }); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("insert failed"); + }); + }); + + // ── GET /projects/:projectId (detail, inline access) ────────────────── + describe("GET /projects/:projectId", () => { + it("returns 404 when the project does not exist", async () => { + supabaseState.tables.projects = { data: null, error: null }; + + const res = await request(app).get("/projects/p1").set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + + it("returns 404 when the caller is neither owner nor shared", async () => { + supabaseState.tables.projects = { + data: { + id: "p1", + user_id: "someone-else", + shared_with: ["other@x.com"], + }, + error: null, + }; + + const res = await request(app).get("/projects/p1").set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + + it("grants access to a shared member (is_owner false)", async () => { + supabaseState.tables.projects = { + data: { + id: "p1", + user_id: "someone-else", + shared_with: ["u1@test.local"], + }, + error: null, + }; + supabaseState.tables.documents = { data: [], error: null }; + supabaseState.tables.project_subfolders = { data: [], error: null }; + + const res = await request(app).get("/projects/p1").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ id: "p1", is_owner: false }); + }); + + it("returns 200 with documents/folders/is_owner when owned", async () => { + supabaseState.tables.projects = { + data: { id: "p1", user_id: "u1", shared_with: null }, + error: null, + }; + supabaseState.tables.documents = { + data: [{ id: "d1", user_id: "u1" }], + error: null, + }; + supabaseState.tables.project_subfolders = { + data: [{ id: "f1" }], + error: null, + }; + + const res = await request(app).get("/projects/p1").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: "p1", + is_owner: true, + documents: [{ id: "d1" }], + folders: [{ id: "f1" }], + }); + }); + }); + + // ── GET /projects/:projectId/documents (checkProjectAccess guard) ───── + describe("GET /projects/:projectId/documents", () => { + it("returns 404 when checkProjectAccess denies access", async () => { + checkProjectAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .get("/projects/p1/documents") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + expect(checkProjectAccess).toHaveBeenCalledTimes(1); + }); + + it("returns 200 with documents when access is granted", async () => { + supabaseState.tables.documents = { + data: [{ id: "d1" }, { id: "d2" }], + error: null, + }; + + const res = await request(app) + .get("/projects/p1/documents") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: "d1" }, { id: "d2" }]); + expect(checkProjectAccess).toHaveBeenCalledTimes(1); + }); + }); + + // ── PATCH /projects/:projectId (sharing normalisation) ──────────────── + describe("PATCH /projects/:projectId", () => { + it("returns 400 when sharing the project with yourself", async () => { + const res = await request(app) + .patch("/projects/p1") + .set(...AUTH) + .send({ shared_with: ["u1@test.local"] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "You cannot share a project with yourself.", + ); + }); + + it("returns 404 when the update matches no owned project", async () => { + supabaseState.tables.projects = { data: null, error: null }; + + const res = await request(app) + .patch("/projects/p1") + .set(...AUTH) + .send({ name: "Renamed" }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + }); + + // ── DELETE /projects/:projectId ─────────────────────────────────────── + describe("DELETE /projects/:projectId", () => { + it("returns 404 when nothing was deleted", async () => { + deleteUserProjects.mockResolvedValue(0); + + const res = await request(app).delete("/projects/p1").set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + + it("returns 204 when the project is deleted", async () => { + deleteUserProjects.mockResolvedValue(1); + + const res = await request(app).delete("/projects/p1").set(...AUTH); + + expect(res.status).toBe(204); + // Signature is deleteUserProjects(db, userId, [projectId]). + expect(deleteUserProjects).toHaveBeenCalledWith( + expect.anything(), + "u1", + ["p1"], + ); + }); + + it("returns 500 when deletion throws", async () => { + deleteUserProjects.mockRejectedValue(new Error("cascade failed")); + + const res = await request(app).delete("/projects/p1").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("cascade failed"); + }); + }); +}); diff --git a/backend/src/__tests__/integration/stack.supabase.test.ts b/backend/src/__tests__/integration/stack.supabase.test.ts new file mode 100644 index 0000000..b74a051 --- /dev/null +++ b/backend/src/__tests__/integration/stack.supabase.test.ts @@ -0,0 +1,146 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// Stack-level integration test: exercises the REAL Supabase stack (GoTrue auth + +// Postgres RLS) rather than mocks. This is the harness that makes pinning a fixed +// Supabase version set safe — it's what you re-run on every image bump to prove +// the auth↔API contract and the deny-all RLS firewall still hold. It also anchors +// the security model's central claim: RLS denies the user/anon path, and the API +// reaches data only via the service-role key. +// +// Gated: skipped unless a stack is provided (default CI unit run skips it). +// Locally: `supabase start`, then export the printed keys as: +// SUPABASE_TEST_URL, SUPABASE_TEST_SERVICE_ROLE_KEY, SUPABASE_TEST_ANON_KEY +const url = process.env.SUPABASE_TEST_URL; +const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY; +const anonKey = process.env.SUPABASE_TEST_ANON_KEY; +const maybeDescribe = + url && serviceKey && anonKey ? describe : describe.skip; + +// Every public table the app owns (backend/schema.sql + migrations). The +// anon/user path must never return rows from any of these (deny-all); a +// regression that ships a table without RLS — or with a permissive policy — +// trips the leak sweep below. A table missing from an older local stack +// returns an error (no rows), which never counts as a leak. +const PUBLIC_TABLES = [ + "chat_messages", "chats", "courtlistener_citation_index", + "courtlistener_opinion_cluster_index", "document_edits", + "document_versions", "documents", "hidden_workflows", "library_folders", + "project_subfolders", "projects", "tabular_cells", + "tabular_review_chat_messages", "tabular_review_chats", "tabular_reviews", + "user_api_keys", "user_mcp_connector_tools", "user_mcp_connectors", + "user_mcp_oauth_states", "user_mcp_oauth_tokens", + "user_mcp_tool_audit_logs", "user_profiles", + "workflow_open_source_submissions", "workflow_shares", "workflows", +]; + +maybeDescribe("Supabase stack — auth contract + RLS deny-all firewall", () => { + const password = "StackTest1!"; + const emailA = `stack-a-${Date.now()}@test.local`; + const emailB = `stack-b-${Date.now()}@test.local`; + + let admin: SupabaseClient; // service-role: BYPASSRLS, the app's data path + let userA = ""; + let userB = ""; + let tokenA = ""; + let projectId = ""; + + // A client acting as a signed-in end user (anon key + the user's JWT): this is + // the path RLS must fence off. + const asUser = (token: string) => + createClient(url!, anonKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + global: { headers: { Authorization: `Bearer ${token}` } }, + }); + + beforeAll(async () => { + admin = createClient(url!, serviceKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const a = await admin.auth.admin.createUser({ + email: emailA, password, email_confirm: true, + }); + const b = await admin.auth.admin.createUser({ + email: emailB, password, email_confirm: true, + }); + if (a.error || !a.data.user) throw a.error ?? new Error("no user A"); + if (b.error || !b.data.user) throw b.error ?? new Error("no user B"); + userA = a.data.user.id; + userB = b.data.user.id; + + // Sign in as A to get a real access token (the token the API middleware + // validates via auth.getUser). + const signIn = await createClient(url!, anonKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + }).auth.signInWithPassword({ email: emailA, password }); + if (signIn.error || !signIn.data.session) { + throw signIn.error ?? new Error("no session for A"); + } + tokenA = signIn.data.session.access_token; + + // Seed one row owned by A via the service role (the app's real write path). + const proj = await admin + .from("projects") + .insert({ user_id: userA, name: "Stack Test Project" }) + .select("id") + .single(); + if (proj.error || !proj.data) throw proj.error ?? new Error("no project"); + projectId = proj.data.id; + }); + + afterAll(async () => { + if (projectId) await admin.from("projects").delete().eq("id", projectId); + if (userA) await admin.auth.admin.deleteUser(userA); + if (userB) await admin.auth.admin.deleteUser(userB); + }); + + it("auth contract: the access token resolves to its user (middleware path)", async () => { + const { data, error } = await admin.auth.getUser(tokenA); + expect(error).toBeNull(); + expect(data.user?.id).toBe(userA); + expect(data.user?.email).toBe(emailA); + }); + + it("RLS: the service role sees seeded rows the owner cannot see via the user path", async () => { + // Service role (app data path) sees the project… + const svc = await admin + .from("projects").select("id").eq("id", projectId); + expect(svc.error).toBeNull(); + expect(svc.data ?? []).toHaveLength(1); + + // …but the owner, going through the user/anon path, sees zero rows — + // deny-all RLS is the firewall; the app must use the service role. + const owner = await asUser(tokenA) + .from("projects").select("id").eq("id", projectId); + expect(owner.data ?? []).toHaveLength(0); + + // And the owner's profile (if any) is equally invisible to the user path. + const prof = await asUser(tokenA) + .from("user_profiles").select("user_id").eq("user_id", userA); + expect(prof.data ?? []).toHaveLength(0); + }); + + it("tenant isolation: user B cannot read user A's project via the user path", async () => { + const signInB = await createClient(url!, anonKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + }).auth.signInWithPassword({ email: emailB, password }); + const tokenB = signInB.data.session!.access_token; + + const cross = await asUser(tokenB) + .from("projects").select("id").eq("id", projectId); + expect(cross.data ?? []).toHaveLength(0); + }); + + it("leak sweep: no public table returns rows to the authenticated user path", async () => { + const client = asUser(tokenA); + const leaks: string[] = []; + for (const table of PUBLIC_TABLES) { + const { data } = await client.from(table).select("*").limit(1); + if ((data ?? []).length > 0) leaks.push(table); + } + // Any table returning rows to a normal user means RLS is missing or a + // policy is permissive — the exact regression this guards against. + expect(leaks).toEqual([]); + }); +}); diff --git a/backend/src/__tests__/integration/tabular.routes.test.ts b/backend/src/__tests__/integration/tabular.routes.test.ts new file mode 100644 index 0000000..c828148 --- /dev/null +++ b/backend/src/__tests__/integration/tabular.routes.test.ts @@ -0,0 +1,713 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +// --------------------------------------------------------------------------- +// Hoisted mock fns reconfigured per-test. Access helpers + model settings are +// mocked so the tests drive review-access decisions, document-access filtering +// and the missing-API-key guard without touching real Supabase / LLM IO. The +// streaming endpoints (chat/generate) are only exercised up to their GUARDS — +// the SSE loop itself is never reached in these tests. +// --------------------------------------------------------------------------- +const { + ensureReviewAccess, + checkProjectAccess, + filterAccessibleDocumentIds, + getUserModelSettings, + loadActiveVersion, +} = vi.hoisted(() => ({ + ensureReviewAccess: vi.fn(), + checkProjectAccess: vi.fn(), + filterAccessibleDocumentIds: vi.fn(), + getUserModelSettings: vi.fn(), + loadActiveVersion: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Configurable Supabase stub (mirrors projects.routes.test). Each test seeds +// `supabaseState` in beforeEach; terminal query operations resolve to the +// per-table result, rpc() resolves to a per-call result. Insert payloads are +// recorded so tests can assert on what got persisted. +// --------------------------------------------------------------------------- +type QueryResult = { data: unknown; error: unknown }; + +let supabaseState: { + rpc: QueryResult; + tables: Record; + inserts: { table: string; payload: unknown }[]; +}; + +function resetSupabaseState() { + supabaseState = { + rpc: { data: [], error: null }, + tables: {}, + inserts: [], + }; +} +resetSupabaseState(); + +function resultForTable(table: string): QueryResult { + return supabaseState.tables[table] ?? { data: null, error: null }; +} + +function makeQuery(table: string) { + const q: Record = {}; + const chain = [ + "select", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.insert = vi.fn((payload: unknown) => { + supabaseState.inserts.push({ table, payload }); + return q; + }); + q.single = vi.fn(() => Promise.resolve(resultForTable(table))); + q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table))); + q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(resultForTable(table)).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn((table: string) => makeQuery(table)), + rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +vi.mock("../../lib/access", () => ({ + ensureReviewAccess: (...args: unknown[]) => ensureReviewAccess(...args), + checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args), + filterAccessibleDocumentIds: (...args: unknown[]) => + filterAccessibleDocumentIds(...args), + ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + listAccessibleProjectIds: vi.fn(async () => []), +})); + +vi.mock("../../lib/userSettings", () => ({ + getUserModelSettings: (...args: unknown[]) => getUserModelSettings(...args), + getUserApiKeys: vi.fn(async () => ({})), +})); + +// Version-path enrichment + active-version resolution hit the DB in real life; +// no-op them so route responses are driven purely by the table stubs. +vi.mock("../../lib/documentVersions", () => ({ + attachActiveVersionPaths: vi.fn(async () => {}), + attachLatestVersionNumbers: vi.fn(async () => {}), + loadActiveVersion: (...args: unknown[]) => loadActiveVersion(...args), +})); + +import { app } from "../../app"; + +const AUTH = ["Authorization", "Bearer test"] as const; + +describe("tabular.routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetSupabaseState(); + // Default: caller is the owner with full access. + ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: true }); + checkProjectAccess.mockResolvedValue({ + ok: true, + isOwner: true, + project: { id: "p1", user_id: "u1", shared_with: null }, + }); + // Default: every requested doc is accessible (identity passthrough). + filterAccessibleDocumentIds.mockImplementation( + async (ids: string[]) => ids, + ); + getUserModelSettings.mockResolvedValue({ + title_model: "claude-haiku-4-5", + tabular_model: "claude-sonnet-4-5", + legal_research_us: false, + api_keys: { claude: "sk-test" }, + }); + loadActiveVersion.mockResolvedValue(null); + }); + + // ── GET /tabular-review (overview) ──────────────────────────────────── + describe("GET /tabular-review", () => { + it("returns the overview rows from the RPC", async () => { + supabaseState.rpc = { + data: [{ id: "r1", title: "Alpha" }], + error: null, + }; + + const res = await request(app).get("/tabular-review").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: "r1", title: "Alpha" }]); + }); + + it("returns 500 with detail when the RPC errors", async () => { + supabaseState.rpc = { data: null, error: { message: "boom" } }; + + const res = await request(app).get("/tabular-review").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("boom"); + }); + }); + + // ── POST /tabular-review (create) ───────────────────────────────────── + describe("POST /tabular-review", () => { + it("creates a review (201) and only persists accessible documents", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r9", title: "Gamma", document_ids: ["d1"] }, + error: null, + }; + // d2 is not accessible — it must be filtered out of the insert. + filterAccessibleDocumentIds.mockResolvedValue(["d1"]); + + const res = await request(app) + .post("/tabular-review") + .set(...AUTH) + .send({ + title: "Gamma", + document_ids: ["d1", "d2"], + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ id: "r9" }); + + const reviewInsert = supabaseState.inserts.find( + (i) => i.table === "tabular_reviews", + ); + expect(reviewInsert?.payload).toMatchObject({ + document_ids: ["d1"], + }); + // Cells are created for accessible docs × columns only (1 × 1). + const cellInsert = supabaseState.inserts.find( + (i) => i.table === "tabular_cells", + ); + expect(cellInsert?.payload).toEqual([ + { + review_id: "r9", + document_id: "d1", + column_index: 0, + status: "pending", + }, + ]); + }); + + it("returns 404 when project access is denied", async () => { + checkProjectAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review") + .set(...AUTH) + .send({ + project_id: "p-nope", + document_ids: [], + columns_config: [], + }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + + it("returns 500 when the review insert errors", async () => { + supabaseState.tables.tabular_reviews = { + data: null, + error: { message: "insert failed" }, + }; + + const res = await request(app) + .post("/tabular-review") + .set(...AUTH) + .send({ document_ids: [], columns_config: [] }); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("insert failed"); + }); + }); + + // ── GET /tabular-review/:reviewId (detail) ──────────────────────────── + describe("GET /tabular-review/:reviewId", () => { + it("returns 404 when the review does not exist", async () => { + supabaseState.tables.tabular_reviews = { data: null, error: null }; + + const res = await request(app) + .get("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .get("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 200 with review/cells/documents + is_owner", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + document_ids: ["d1"], + columns_config: [], + }, + error: null, + }; + supabaseState.tables.tabular_cells = { + data: [ + { + id: "c1", + document_id: "d1", + column_index: 0, + content: null, + status: "pending", + }, + ], + error: null, + }; + supabaseState.tables.documents = { + data: [{ id: "d1", current_version_id: null }], + error: null, + }; + + const res = await request(app) + .get("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body.review).toMatchObject({ id: "r1", is_owner: true }); + expect(res.body.cells).toHaveLength(1); + expect(res.body.documents).toEqual([ + { id: "d1", current_version_id: null }, + ]); + }); + }); + + // ── PATCH /tabular-review/:reviewId ─────────────────────────────────── + describe("PATCH /tabular-review/:reviewId", () => { + it("returns 400 when project_id is an invalid type", async () => { + const res = await request(app) + .patch("/tabular-review/r1") + .set(...AUTH) + .send({ project_id: 123 }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "project_id must be a non-empty string or null", + ); + }); + + it("returns 400 when sharing the review with yourself", async () => { + const res = await request(app) + .patch("/tabular-review/r1") + .set(...AUTH) + .send({ shared_with: ["U1@Test.Local"] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "You cannot share a tabular review with yourself.", + ); + }); + + it("returns 404 when the review does not exist", async () => { + supabaseState.tables.tabular_reviews = { data: null, error: null }; + + const res = await request(app) + .patch("/tabular-review/r1") + .set(...AUTH) + .send({ title: "Renamed" }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 403 when a non-owner edits columns_config", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: "p1" }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: false }); + + const res = await request(app) + .patch("/tabular-review/r1") + .set(...AUTH) + .send({ columns_config: [{ index: 0, name: "X", prompt: "p" }] }); + + expect(res.status).toBe(403); + expect(res.body.detail).toBe("Only the review owner can change columns"); + }); + }); + + // ── DELETE /tabular-review/:reviewId ────────────────────────────────── + describe("DELETE /tabular-review/:reviewId", () => { + it("returns 204 on success", async () => { + supabaseState.tables.tabular_reviews = { data: null, error: null }; + + const res = await request(app) + .delete("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(204); + }); + + it("returns 500 when the delete errors", async () => { + supabaseState.tables.tabular_reviews = { + data: null, + error: { message: "delete failed" }, + }; + + const res = await request(app) + .delete("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("delete failed"); + }); + }); + + // ── POST /tabular-review/:reviewId/clear-cells ──────────────────────── + describe("POST /tabular-review/:reviewId/clear-cells", () => { + it("returns 400 when document_ids is missing", async () => { + const res = await request(app) + .post("/tabular-review/r1/clear-cells") + .set(...AUTH) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("document_ids is required"); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review/r1/clear-cells") + .set(...AUTH) + .send({ document_ids: ["d1"] }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 204 on success", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "u1", project_id: null }, + error: null, + }; + + const res = await request(app) + .post("/tabular-review/r1/clear-cells") + .set(...AUTH) + .send({ document_ids: ["d1"] }); + + expect(res.status).toBe(204); + }); + }); + + // ── POST /tabular-review/:reviewId/regenerate-cell ──────────────────── + describe("POST /tabular-review/:reviewId/regenerate-cell", () => { + it("returns 400 when document_id / column_index are missing", async () => { + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "document_id and column_index are required", + ); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({ document_id: "d1", column_index: 0 }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 400 when the column is not configured", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [{ index: 5, name: "Other", prompt: "p" }], + }, + error: null, + }; + + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({ document_id: "d1", column_index: 0 }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("Column not found"); + }); + + it("returns 404 when the document is not accessible", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }, + error: null, + }; + filterAccessibleDocumentIds.mockResolvedValue([]); + + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({ document_id: "d-forbidden", column_index: 0 }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Document not found"); + }); + + it("returns 422 with missing_api_key when the model key is absent", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }, + error: null, + }; + supabaseState.tables.documents = { + data: { id: "d1", current_version_id: null }, + error: null, + }; + getUserModelSettings.mockResolvedValue({ + title_model: "claude-haiku-4-5", + tabular_model: "claude-sonnet-4-5", + legal_research_us: false, + api_keys: {}, + }); + + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({ document_id: "d1", column_index: 0 }); + + expect(res.status).toBe(422); + expect(res.body.code).toBe("missing_api_key"); + expect(res.body.provider).toBe("claude"); + }); + }); + + // ── POST /tabular-review/:reviewId/generate (streaming GUARDS only) ─── + describe("POST /tabular-review/:reviewId/generate", () => { + it("returns 404 when the review does not exist", async () => { + supabaseState.tables.tabular_reviews = { data: null, error: null }; + + const res = await request(app) + .post("/tabular-review/r1/generate") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review/r1/generate") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 400 when no columns are configured", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [], + }, + error: null, + }; + + const res = await request(app) + .post("/tabular-review/r1/generate") + .set(...AUTH); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("No columns configured"); + }); + + it("returns 422 missing_api_key before streaming when the key is absent", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }, + error: null, + }; + supabaseState.tables.tabular_cells = { data: [], error: null }; + getUserModelSettings.mockResolvedValue({ + title_model: "claude-haiku-4-5", + tabular_model: "claude-sonnet-4-5", + legal_research_us: false, + api_keys: {}, + }); + + const res = await request(app) + .post("/tabular-review/r1/generate") + .set(...AUTH); + + expect(res.status).toBe(422); + expect(res.body.code).toBe("missing_api_key"); + }); + }); + + // ── POST /tabular-review/:reviewId/chat (streaming GUARDS only) ─────── + describe("POST /tabular-review/:reviewId/chat", () => { + it("returns 400 when no user message is present", async () => { + const res = await request(app) + .post("/tabular-review/r1/chat") + .set(...AUTH) + .send({ messages: [{ role: "assistant", content: "hi" }] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("messages must include a user message"); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review/r1/chat") + .set(...AUTH) + .send({ messages: [{ role: "user", content: "hello" }] }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 422 missing_api_key before streaming when the key is absent", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [], + }, + error: null, + }; + supabaseState.tables.tabular_cells = { data: [], error: null }; + getUserModelSettings.mockResolvedValue({ + title_model: "claude-haiku-4-5", + tabular_model: "claude-sonnet-4-5", + legal_research_us: false, + api_keys: {}, + }); + + const res = await request(app) + .post("/tabular-review/r1/chat") + .set(...AUTH) + .send({ messages: [{ role: "user", content: "hello" }] }); + + expect(res.status).toBe(422); + expect(res.body.code).toBe("missing_api_key"); + }); + }); + + // ── GET /tabular-review/:reviewId/chats ─────────────────────────────── + describe("GET /tabular-review/:reviewId/chats", () => { + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .get("/tabular-review/r1/chats") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns the chat list when access is granted", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "u1", project_id: null }, + error: null, + }; + supabaseState.tables.tabular_review_chats = { + data: [{ id: "chat-1", title: "T", user_id: "u1" }], + error: null, + }; + + const res = await request(app) + .get("/tabular-review/r1/chats") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { id: "chat-1", title: "T", user_id: "u1" }, + ]); + }); + }); +}); diff --git a/backend/src/__tests__/integration/user.routes.test.ts b/backend/src/__tests__/integration/user.routes.test.ts new file mode 100644 index 0000000..d47b5bb --- /dev/null +++ b/backend/src/__tests__/integration/user.routes.test.ts @@ -0,0 +1,588 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +// --------------------------------------------------------------------------- +// Hoisted mock fns we reconfigure per-test. These cover the three security +// surfaces this suite baselines: +// - the MFA route guard (requireMfaIfEnrolled) +// - the API-key crypto boundary (userApiKeys) +// - the destructive data export / deletion helpers (userDataExport / +// userDataCleanup) +// Each is a vi.fn so we can both reconfigure behaviour and assert call args. +// --------------------------------------------------------------------------- +const { + requireMfaIfEnrolled, + getUserApiKeyStatus, + saveUserApiKey, + hasEnvApiKey, + normalizeApiKeyProvider, + deleteAllUserChats, + deleteAllUserTabularReviews, + deleteUserAccountData, + deleteUserProjects, + buildUserAccountExport, + buildUserChatsExport, + buildUserTabularReviewsExport, +} = vi.hoisted(() => ({ + requireMfaIfEnrolled: vi.fn(), + getUserApiKeyStatus: vi.fn(), + saveUserApiKey: vi.fn(), + hasEnvApiKey: vi.fn(), + normalizeApiKeyProvider: vi.fn(), + deleteAllUserChats: vi.fn(), + deleteAllUserTabularReviews: vi.fn(), + deleteUserAccountData: vi.fn(), + deleteUserProjects: vi.fn(), + buildUserAccountExport: vi.fn(), + buildUserChatsExport: vi.fn(), + buildUserTabularReviewsExport: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Configurable Supabase stub. The only route in this suite that reaches the +// DB directly is GET /user/profile (via loadProfile → selectProfile). Tests +// seed `supabaseState.tables.user_profiles`; terminal query ops resolve to the +// per-table result and auth.admin methods are stubbed where routes call them. +// --------------------------------------------------------------------------- +type QueryResult = { data: unknown; error: unknown }; + +let supabaseState: { + tables: Record; + adminGetUserById: QueryResult; + adminDeleteUser: { error: unknown }; +}; + +function resetSupabaseState() { + supabaseState = { + tables: {}, + adminGetUserById: { + data: { user: { id: "u1", factors: [] } }, + error: null, + }, + adminDeleteUser: { error: null }, + }; +} +resetSupabaseState(); + +function resultForTable(table: string): QueryResult { + return supabaseState.tables[table] ?? { data: null, error: null }; +} + +function makeQuery(table: string) { + const q: Record = {}; + const chain = [ + "select", "update", "delete", "upsert", "insert", + "eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.single = vi.fn(() => Promise.resolve(resultForTable(table))); + q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table))); + q.then = ( + resolve: (v: unknown) => unknown, + reject?: (e: unknown) => unknown, + ) => Promise.resolve(resultForTable(table)).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn((table: string) => makeQuery(table)), + rpc: vi.fn(() => Promise.resolve({ data: null, error: null })), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + admin: { + getUserById: vi.fn(() => + Promise.resolve(supabaseState.adminGetUserById), + ), + deleteUser: vi.fn(() => + Promise.resolve(supabaseState.adminDeleteUser), + ), + }, + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +// requireAuth always authenticates u1. requireMfaIfEnrolled is a reconfigurable +// guard so we can drive both the satisfied (next()) and rejected +// (403 mfa_verification_required) paths. +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + res.locals.token = "test-token"; + next(); + }, + requireMfaIfEnrolled: (req: unknown, res: unknown, next: () => void) => + requireMfaIfEnrolled(req, res, next), +})); + +// API-key crypto boundary: the route must funnel writes through saveUserApiKey +// (which encrypts) and never echo plaintext — getUserApiKeyStatus returns +// presence-only booleans. getUserApiKeys must be exported too — lib/userSettings +// imports it at module load. +vi.mock("../../lib/userApiKeys", () => ({ + getUserApiKeyStatus: (...args: unknown[]) => getUserApiKeyStatus(...args), + saveUserApiKey: (...args: unknown[]) => saveUserApiKey(...args), + hasEnvApiKey: (...args: unknown[]) => hasEnvApiKey(...args), + normalizeApiKeyProvider: (...args: unknown[]) => + normalizeApiKeyProvider(...args), + getUserApiKeys: vi.fn(async () => ({})), +})); + +vi.mock("../../lib/userDataCleanup", () => ({ + deleteAllUserChats: (...args: unknown[]) => deleteAllUserChats(...args), + deleteAllUserTabularReviews: (...args: unknown[]) => + deleteAllUserTabularReviews(...args), + deleteUserAccountData: (...args: unknown[]) => + deleteUserAccountData(...args), + deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args), +})); + +vi.mock("../../lib/userDataExport", () => ({ + buildUserAccountExport: (...args: unknown[]) => + buildUserAccountExport(...args), + buildUserChatsExport: (...args: unknown[]) => buildUserChatsExport(...args), + buildUserTabularReviewsExport: (...args: unknown[]) => + buildUserTabularReviewsExport(...args), + userExportFilename: (kind: string, userId: string) => + `mike-${kind}-export-${userId.slice(0, 8)}.json`, +})); + +import { app } from "../../app"; + +const AUTH = ["Authorization", "Bearer test"] as const; + +// A complete user_profiles row with credits_reset_date in the future so the +// monthly-reset branch in loadProfile is not triggered. +function profileRow(overrides: Record = {}) { + return { + display_name: "Ada", + organisation: "Acme", + message_credits_used: 3, + credits_reset_date: "2999-01-01T00:00:00.000Z", + tier: "Pro", + title_model: null, + tabular_model: "gemini-3-flash-preview", + mfa_on_login: false, + legal_research_us: true, + ...overrides, + }; +} + +const STATUS = { claude: true, openai: false, gemini: false, sources: {} }; + +// The exact 403 body the web client's MFA gate consumes (mirrors the real +// requireMfaIfEnrolled). Used by tests that simulate an unsatisfied factor. +function rejectMfa(_req: unknown, res: any) { + res.status(403).json({ + code: "mfa_verification_required", + detail: "MFA verification required", + }); +} + +describe("user.routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetSupabaseState(); + // Default: MFA satisfied (guard passes through). + requireMfaIfEnrolled.mockImplementation( + (_req: unknown, _res: unknown, next: () => void) => next(), + ); + getUserApiKeyStatus.mockResolvedValue(STATUS); + saveUserApiKey.mockResolvedValue(undefined); + hasEnvApiKey.mockReturnValue(false); + normalizeApiKeyProvider.mockImplementation((v: string) => + ["claude", "openai", "gemini"].includes(v) ? v : null, + ); + deleteAllUserChats.mockResolvedValue(undefined); + deleteAllUserTabularReviews.mockResolvedValue(undefined); + deleteUserAccountData.mockResolvedValue(undefined); + deleteUserProjects.mockResolvedValue(undefined); + buildUserAccountExport.mockResolvedValue({ account: "data" }); + buildUserChatsExport.mockResolvedValue({ chats: "data" }); + buildUserTabularReviewsExport.mockResolvedValue({ reviews: "data" }); + }); + + // ── GET /user/profile (MFA bootstrap path) ──────────────────────────── + describe("GET /user/profile", () => { + it("returns the serialized profile plus apiKeyStatus", async () => { + supabaseState.tables.user_profiles = { + data: profileRow(), + error: null, + }; + + const res = await request(app).get("/user/profile").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + displayName: "Ada", + organisation: "Acme", + messageCreditsUsed: 3, + tier: "Pro", + legalResearchUs: true, + mfaOnLogin: false, + apiKeyStatus: STATUS, + }); + // Presence-only key status — never plaintext. + expect(JSON.stringify(res.body)).not.toContain("sk-"); + }); + + it("is NOT guarded by requireMfaIfEnrolled (bootstrap route)", async () => { + // Even if the MFA factor were unsatisfied, profile must remain + // reachable so the client can render the verification gate. + requireMfaIfEnrolled.mockImplementation(rejectMfa); + supabaseState.tables.user_profiles = { + data: profileRow(), + error: null, + }; + + const res = await request(app).get("/user/profile").set(...AUTH); + + expect(res.status).toBe(200); + expect(requireMfaIfEnrolled).not.toHaveBeenCalled(); + }); + + it("returns 500 with detail when the profile load errors", async () => { + supabaseState.tables.user_profiles = { + data: null, + error: { message: "db down" }, + }; + + const res = await request(app).get("/user/profile").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("db down"); + }); + }); + + // ── POST /user/profile (bootstrap upsert) ───────────────────────────── + describe("POST /user/profile", () => { + it("ensures the profile row and returns ok", async () => { + const res = await request(app).post("/user/profile").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + expect(requireMfaIfEnrolled).not.toHaveBeenCalled(); + }); + }); + + // ── GET /user/api-keys (presence without plaintext) ─────────────────── + describe("GET /user/api-keys", () => { + it("returns the boolean key-status map", async () => { + const res = await request(app).get("/user/api-keys").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual(STATUS); + expect(getUserApiKeyStatus).toHaveBeenCalledWith( + "u1", + expect.anything(), + ); + }); + }); + + // ── PUT /user/api-keys/:provider (crypto + MFA guard) ───────────────── + describe("PUT /user/api-keys/:provider", () => { + it("stores the key via the encryption helper and returns status", async () => { + const res = await request(app) + .put("/user/api-keys/claude") + .set(...AUTH) + .send({ api_key: "sk-secret-value" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual(STATUS); + // The plaintext key must go through saveUserApiKey (the encryption + // boundary), keyed by provider + value, never persisted by the route. + expect(saveUserApiKey).toHaveBeenCalledWith( + "u1", + "claude", + "sk-secret-value", + expect.anything(), + ); + }); + + it("deletes the key when api_key is omitted (null value)", async () => { + const res = await request(app) + .put("/user/api-keys/openai") + .set(...AUTH) + .send({}); + + expect(res.status).toBe(200); + expect(saveUserApiKey).toHaveBeenCalledWith( + "u1", + "openai", + null, + expect.anything(), + ); + }); + + it("returns 400 for an unsupported provider", async () => { + const res = await request(app) + .put("/user/api-keys/bogus") + .set(...AUTH) + .send({ api_key: "x" }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("Unsupported provider"); + expect(saveUserApiKey).not.toHaveBeenCalled(); + }); + + it("returns 409 when the provider is configured by the server env", async () => { + hasEnvApiKey.mockReturnValue(true); + + const res = await request(app) + .put("/user/api-keys/claude") + .set(...AUTH) + .send({ api_key: "sk-x" }); + + expect(res.status).toBe(409); + expect(saveUserApiKey).not.toHaveBeenCalled(); + }); + + it("returns 500 when saving the key throws", async () => { + saveUserApiKey.mockRejectedValue(new Error("kms unavailable")); + + const res = await request(app) + .put("/user/api-keys/claude") + .set(...AUTH) + .send({ api_key: "sk-x" }); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("kms unavailable"); + }); + + it("is rejected with 403 mfa_verification_required when MFA is unsatisfied", async () => { + requireMfaIfEnrolled.mockImplementation(rejectMfa); + + const res = await request(app) + .put("/user/api-keys/claude") + .set(...AUTH) + .send({ api_key: "sk-x" }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ + code: "mfa_verification_required", + detail: "MFA verification required", + }); + // Guarded: the crypto path is never reached. + expect(saveUserApiKey).not.toHaveBeenCalled(); + }); + }); + + // ── Data export endpoints (MFA-guarded, attachment headers) ─────────── + describe("data export endpoints", () => { + it("GET /user/export returns the account export as a JSON attachment", async () => { + const res = await request(app).get("/user/export").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ account: "data" }); + expect(res.headers["content-type"]).toContain("application/json"); + expect(res.headers["content-disposition"]).toContain("attachment"); + expect(res.headers["content-disposition"]).toContain( + "mike-account-export-u1.json", + ); + expect(buildUserAccountExport).toHaveBeenCalledWith( + expect.anything(), + "u1", + "u1@test.local", + ); + }); + + it("GET /user/chats/export returns the chats export", async () => { + const res = await request(app) + .get("/user/chats/export") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ chats: "data" }); + expect(res.headers["content-disposition"]).toContain( + "mike-chats-export-u1.json", + ); + expect(buildUserChatsExport).toHaveBeenCalledTimes(1); + }); + + it("GET /user/tabular-reviews/export returns the reviews export", async () => { + const res = await request(app) + .get("/user/tabular-reviews/export") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ reviews: "data" }); + expect(res.headers["content-disposition"]).toContain( + "mike-tabular-reviews-export-u1.json", + ); + expect(buildUserTabularReviewsExport).toHaveBeenCalledTimes(1); + }); + + it("GET /user/export returns 500 when the builder throws", async () => { + buildUserAccountExport.mockRejectedValue(new Error("export boom")); + + const res = await request(app).get("/user/export").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("export boom"); + }); + + it("GET /user/export is rejected when MFA is unsatisfied", async () => { + requireMfaIfEnrolled.mockImplementation(rejectMfa); + + const res = await request(app).get("/user/export").set(...AUTH); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("mfa_verification_required"); + expect(buildUserAccountExport).not.toHaveBeenCalled(); + }); + }); + + // ── Data deletion endpoints (MFA-guarded, cleanup helpers) ──────────── + describe("data deletion endpoints", () => { + it("DELETE /user/chats invokes deleteAllUserChats and returns 204", async () => { + const res = await request(app).delete("/user/chats").set(...AUTH); + + expect(res.status).toBe(204); + expect(deleteAllUserChats).toHaveBeenCalledWith( + expect.anything(), + "u1", + ); + }); + + it("DELETE /user/projects invokes deleteUserProjects and returns 204", async () => { + const res = await request(app) + .delete("/user/projects") + .set(...AUTH); + + expect(res.status).toBe(204); + expect(deleteUserProjects).toHaveBeenCalledWith( + expect.anything(), + "u1", + ); + }); + + it("DELETE /user/tabular-reviews invokes the cleanup helper and returns 204", async () => { + const res = await request(app) + .delete("/user/tabular-reviews") + .set(...AUTH); + + expect(res.status).toBe(204); + expect(deleteAllUserTabularReviews).toHaveBeenCalledWith( + expect.anything(), + "u1", + ); + }); + + it("DELETE /user/account purges data then deletes the auth user (204)", async () => { + const res = await request(app).delete("/user/account").set(...AUTH); + + expect(res.status).toBe(204); + // Account purge runs the cleanup helper with id + email. + expect(deleteUserAccountData).toHaveBeenCalledWith( + expect.anything(), + "u1", + "u1@test.local", + ); + }); + + it("DELETE /user/account returns 500 when the auth-user delete errors", async () => { + supabaseState.adminDeleteUser = { error: { message: "auth boom" } }; + + const res = await request(app).delete("/user/account").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("auth boom"); + }); + + it("DELETE /user/chats returns 500 when cleanup throws", async () => { + deleteAllUserChats.mockRejectedValue(new Error("cascade failed")); + + const res = await request(app).delete("/user/chats").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("cascade failed"); + }); + + it("DELETE /user/account is rejected when MFA is unsatisfied (no cleanup)", async () => { + requireMfaIfEnrolled.mockImplementation(rejectMfa); + + const res = await request(app).delete("/user/account").set(...AUTH); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("mfa_verification_required"); + expect(deleteUserAccountData).not.toHaveBeenCalled(); + }); + }); + + // ── PATCH /user/security/mfa-login (factor-gated, MFA-guarded) ──────── + describe("PATCH /user/security/mfa-login", () => { + it("returns 400 when enabling without a verified TOTP factor", async () => { + supabaseState.adminGetUserById = { + data: { user: { id: "u1", factors: [] } }, + error: null, + }; + + const res = await request(app) + .patch("/user/security/mfa-login") + .set(...AUTH) + .send({ enabled: true }); + + expect(res.status).toBe(400); + expect(res.body.detail).toContain("authenticator app"); + }); + + it("enables MFA-on-login when a verified TOTP factor exists", async () => { + supabaseState.adminGetUserById = { + data: { + user: { + id: "u1", + factors: [ + { factor_type: "totp", status: "verified" }, + ], + }, + }, + error: null, + }; + supabaseState.tables.user_profiles = { + data: profileRow({ mfa_on_login: true }), + error: null, + }; + + const res = await request(app) + .patch("/user/security/mfa-login") + .set(...AUTH) + .send({ enabled: true }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mfaOnLogin: true }); + }); + + it("returns 400 on a non-boolean enabled field", async () => { + const res = await request(app) + .patch("/user/security/mfa-login") + .set(...AUTH) + .send({ enabled: "yes" }); + + expect(res.status).toBe(400); + }); + + it("is rejected with 403 when MFA is unsatisfied", async () => { + requireMfaIfEnrolled.mockImplementation(rejectMfa); + + const res = await request(app) + .patch("/user/security/mfa-login") + .set(...AUTH) + .send({ enabled: false }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("mfa_verification_required"); + }); + }); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..19cb5c3 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,161 @@ +import "dotenv/config"; +import express from "express"; +import cors from "cors"; +import helmet from "helmet"; +import rateLimit from "express-rate-limit"; +import { chatRouter } from "./routes/chat"; +import { projectsRouter } from "./routes/projects"; +import { projectChatRouter } from "./routes/projectChat"; +import { documentsRouter } from "./routes/documents"; +import { libraryRouter } from "./routes/library"; +import { tabularRouter } from "./routes/tabular"; +import { workflowsRouter } from "./routes/workflows"; +import { userRouter } from "./routes/user"; +import { downloadsRouter } from "./routes/downloads"; +import { caseLawRouter } from "./routes/caseLaw"; + +export const app = express(); +const isProduction = process.env.NODE_ENV === "production"; + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function minutes(value: number): number { + return value * 60 * 1000; +} + +function hours(value: number): number { + return minutes(value * 60); +} + +function makeLimiter(options: { + windowMs: number; + max: number; + message?: string; +}) { + return rateLimit({ + windowMs: options.windowMs, + max: options.max, + standardHeaders: true, + legacyHeaders: false, + skip: (req) => req.method === "OPTIONS", + message: { + detail: + options.message ?? "Too many requests. Please try again later.", + }, + }); +} + +const generalLimiter = makeLimiter({ + windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)), + max: envInt("RATE_LIMIT_GENERAL_MAX", 300), +}); + +const chatLimiter = makeLimiter({ + windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)), + max: envInt("RATE_LIMIT_CHAT_MAX", 30), + message: "Too many chat requests. Please try again later.", +}); + +const chatCreateLimiter = makeLimiter({ + windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)), + max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60), +}); + +const uploadLimiter = makeLimiter({ + windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)), + max: envInt("RATE_LIMIT_UPLOAD_MAX", 50), + message: "Too many upload requests. Please try again later.", +}); + +const exportLimiter = makeLimiter({ + windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)), + max: envInt("RATE_LIMIT_EXPORT_MAX", 10), + message: "Too many export requests. Please try again later.", +}); + +const dataDeleteLimiter = makeLimiter({ + windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)), + max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20), + message: "Too many data deletion requests. Please try again later.", +}); + +function jsonLimitForPath(path: string): string { + return "50mb"; +} + +app.disable("x-powered-by"); +app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1)); + +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'none'"], + baseUri: ["'none'"], + frameAncestors: ["'none'"], + }, + }, + crossOriginEmbedderPolicy: false, + hsts: isProduction + ? { + maxAge: 15552000, + includeSubDomains: true, + } + : false, + referrerPolicy: { policy: "no-referrer" }, + }), +); + +app.use( + cors({ + origin: process.env.FRONTEND_URL ?? "http://localhost:3000", + credentials: true, + }), +); + +app.use(generalLimiter); + +app.post("/chat", chatLimiter); +app.post("/projects/:projectId/chat", chatLimiter); +app.post("/tabular-review/:reviewId/chat", chatLimiter); +app.post("/tabular-review/:reviewId/generate", chatLimiter); +app.post("/chat/create", chatCreateLimiter); +app.post("/chat/:chatId/generate-title", chatCreateLimiter); +app.post("/single-documents", uploadLimiter); +app.post("/library/:kind/documents", uploadLimiter); +app.post("/single-documents/:documentId/versions", uploadLimiter); +app.put( + "/single-documents/:documentId/versions/:versionId/file", + uploadLimiter, +); +app.post("/projects/:projectId/documents", uploadLimiter); +app.get("/user/export", exportLimiter); +app.get("/user/chats/export", exportLimiter); +app.get("/user/tabular-reviews/export", exportLimiter); +app.delete("/user/account", dataDeleteLimiter); +app.delete("/user/chats", dataDeleteLimiter); +app.delete("/user/projects", dataDeleteLimiter); +app.delete("/user/tabular-reviews", dataDeleteLimiter); + +app.use((req, res, next) => + express.json({ limit: jsonLimitForPath(req.path) })(req, res, next), +); + +app.use("/chat", chatRouter); +app.use("/projects", projectsRouter); +app.use("/projects/:projectId/chat", projectChatRouter); +app.use("/single-documents", documentsRouter); +app.use("/library", libraryRouter); +app.use("/tabular-review", tabularRouter); +app.use("/workflows", workflowsRouter); +app.use("/user", userRouter); +app.use("/users", userRouter); +app.use("/download", downloadsRouter); +app.use("/case-law", caseLawRouter); + +app.get("/health", (_req, res) => res.json({ ok: true })); diff --git a/backend/src/index.ts b/backend/src/index.ts index b8d36cf..ddf28c7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,165 +1,6 @@ -import "dotenv/config"; -import express from "express"; -import cors from "cors"; -import helmet from "helmet"; -import rateLimit from "express-rate-limit"; -import { chatRouter } from "./routes/chat"; -import { projectsRouter } from "./routes/projects"; -import { projectChatRouter } from "./routes/projectChat"; -import { documentsRouter } from "./routes/documents"; -import { libraryRouter } from "./routes/library"; -import { tabularRouter } from "./routes/tabular"; -import { workflowsRouter } from "./routes/workflows"; -import { userRouter } from "./routes/user"; -import { downloadsRouter } from "./routes/downloads"; -import { caseLawRouter } from "./routes/caseLaw"; +import { app } from "./app"; -const app = express(); const PORT = process.env.PORT ?? 3001; -const isProduction = process.env.NODE_ENV === "production"; - -function envInt(name: string, fallback: number): number { - const raw = process.env[name]; - if (!raw) return fallback; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - -function minutes(value: number): number { - return value * 60 * 1000; -} - -function hours(value: number): number { - return minutes(value * 60); -} - -function makeLimiter(options: { - windowMs: number; - max: number; - message?: string; -}) { - return rateLimit({ - windowMs: options.windowMs, - max: options.max, - standardHeaders: true, - legacyHeaders: false, - skip: (req) => req.method === "OPTIONS", - message: { - detail: - options.message ?? "Too many requests. Please try again later.", - }, - }); -} - -const generalLimiter = makeLimiter({ - windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)), - max: envInt("RATE_LIMIT_GENERAL_MAX", 300), -}); - -const chatLimiter = makeLimiter({ - windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)), - max: envInt("RATE_LIMIT_CHAT_MAX", 30), - message: "Too many chat requests. Please try again later.", -}); - -const chatCreateLimiter = makeLimiter({ - windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)), - max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60), -}); - -const uploadLimiter = makeLimiter({ - windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)), - max: envInt("RATE_LIMIT_UPLOAD_MAX", 50), - message: "Too many upload requests. Please try again later.", -}); - -const exportLimiter = makeLimiter({ - windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)), - max: envInt("RATE_LIMIT_EXPORT_MAX", 10), - message: "Too many export requests. Please try again later.", -}); - -const dataDeleteLimiter = makeLimiter({ - windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)), - max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20), - message: "Too many data deletion requests. Please try again later.", -}); - -function jsonLimitForPath(path: string): string { - return "50mb"; -} - -app.disable("x-powered-by"); -app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1)); - -app.use( - helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'none'"], - baseUri: ["'none'"], - frameAncestors: ["'none'"], - }, - }, - crossOriginEmbedderPolicy: false, - hsts: isProduction - ? { - maxAge: 15552000, - includeSubDomains: true, - } - : false, - referrerPolicy: { policy: "no-referrer" }, - }), -); - -app.use( - cors({ - origin: process.env.FRONTEND_URL ?? "http://localhost:3000", - credentials: true, - }), -); - -app.use(generalLimiter); - -app.post("/chat", chatLimiter); -app.post("/projects/:projectId/chat", chatLimiter); -app.post("/tabular-review/:reviewId/chat", chatLimiter); -app.post("/tabular-review/:reviewId/generate", chatLimiter); -app.post("/chat/create", chatCreateLimiter); -app.post("/chat/:chatId/generate-title", chatCreateLimiter); -app.post("/single-documents", uploadLimiter); -app.post("/library/:kind/documents", uploadLimiter); -app.post("/single-documents/:documentId/versions", uploadLimiter); -app.put( - "/single-documents/:documentId/versions/:versionId/file", - uploadLimiter, -); -app.post("/projects/:projectId/documents", uploadLimiter); -app.get("/user/export", exportLimiter); -app.get("/user/chats/export", exportLimiter); -app.get("/user/tabular-reviews/export", exportLimiter); -app.delete("/user/account", dataDeleteLimiter); -app.delete("/user/chats", dataDeleteLimiter); -app.delete("/user/projects", dataDeleteLimiter); -app.delete("/user/tabular-reviews", dataDeleteLimiter); - -app.use((req, res, next) => - express.json({ limit: jsonLimitForPath(req.path) })(req, res, next), -); - -app.use("/chat", chatRouter); -app.use("/projects", projectsRouter); -app.use("/projects/:projectId/chat", projectChatRouter); -app.use("/single-documents", documentsRouter); -app.use("/library", libraryRouter); -app.use("/tabular-review", tabularRouter); -app.use("/workflows", workflowsRouter); -app.use("/user", userRouter); -app.use("/users", userRouter); -app.use("/download", downloadsRouter); -app.use("/case-law", caseLawRouter); - -app.get("/health", (_req, res) => res.json({ ok: true })); app.listen(PORT, () => { console.log(`Mike backend running on port ${PORT}`); From 6aed350a3cadda5abb30345210fab19401b5a1d7 Mon Sep 17 00:00:00 2001 From: QA Runner Date: Wed, 22 Jul 2026 10:22:26 -0700 Subject: [PATCH 2/4] fix: repair JSON corrupted by the merge of main (package.json + lockfile) The "Merge branch 'main'" commit (71a7aba) auto-merged backend/package.json and backend/package-lock.json without raising a conflict, but git's line-level merge produced invalid JSON in both files: package.json lost the comma after the new "test:stack" script, and package-lock.json lost the two closing-brace lines of the supertest/cookie-signature entry. npm treats an unparseable lockfile as absent, which is why CI failed with the misleading "npm ci can only install with an existing package-lock.json". package.json: restore the comma. package-lock.json: rebuilt from main's known-good copy via `npm install --package-lock-only` against this branch's package.json. Verified locally: npm ci, npm test (259 passed), npm run build. Co-Authored-By: Claude Fable 5 --- backend/package-lock.json | 2 ++ backend/package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 11c4367..7bed718 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -6806,6 +6806,8 @@ "license": "MIT", "engines": { "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index e64e591..0223d9a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,7 @@ "build": "tsc", "start": "node dist/index.js", "test": "vitest run", - "test:stack": "bash scripts/test-stack.sh" + "test:stack": "bash scripts/test-stack.sh", "test:coverage": "vitest run --coverage" }, "dependencies": { From 9399fce86d9c2f6b6fc8c06899abbf6535762de3 Mon Sep 17 00:00:00 2001 From: willchen96 Date: Thu, 23 Jul 2026 19:08:08 +0800 Subject: [PATCH 3/4] fix stack test database initialization --- backend/schema.sql | 7 +++++ backend/scripts/test-stack.sh | 29 +++++++++++++++++-- .../integration/access.supabase.test.ts | 17 +++++++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/backend/schema.sql b/backend/schema.sql index e475b76..48957b8 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -885,3 +885,10 @@ revoke all on public.user_mcp_connector_tools from anon, authenticated; revoke all on public.user_mcp_tool_audit_logs from anon, authenticated; revoke all on public.courtlistener_citation_index from anon, authenticated; revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated; + +-- Tables created by this file are owned by the database bootstrap role. The +-- backend connects as service_role, so grant it the privileges that the direct +-- browser roles above intentionally do not have. RLS is still enabled as +-- defense in depth; service_role bypasses it for the backend data path. +grant all privileges on all tables in schema public to service_role; +grant all privileges on all sequences in schema public to service_role; diff --git a/backend/scripts/test-stack.sh b/backend/scripts/test-stack.sh index 2f647a7..f75deae 100755 --- a/backend/scripts/test-stack.sh +++ b/backend/scripts/test-stack.sh @@ -9,6 +9,10 @@ # npm run test:stack (from backend/) set -euo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +SCHEMA_FILE="$BACKEND_DIR/schema.sql" + if ! command -v supabase >/dev/null 2>&1; then echo "supabase CLI not found. Install: brew install supabase/tap/supabase" >&2 exit 1 @@ -24,14 +28,35 @@ read_key() { node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>pro SUPABASE_TEST_URL="$(read_key API_URL)" SUPABASE_TEST_SERVICE_ROLE_KEY="$(read_key SERVICE_ROLE_KEY)" SUPABASE_TEST_ANON_KEY="$(read_key ANON_KEY)" +SUPABASE_TEST_DB_URL="$(read_key DB_URL)" -if [[ -z "$SUPABASE_TEST_URL" || -z "$SUPABASE_TEST_SERVICE_ROLE_KEY" || -z "$SUPABASE_TEST_ANON_KEY" ]]; then - echo "Could not read API_URL/SERVICE_ROLE_KEY/ANON_KEY from 'supabase status'." >&2 +if [[ -z "$SUPABASE_TEST_URL" || -z "$SUPABASE_TEST_SERVICE_ROLE_KEY" || -z "$SUPABASE_TEST_ANON_KEY" || -z "$SUPABASE_TEST_DB_URL" ]]; then + echo "Could not read API_URL/DB_URL/SERVICE_ROLE_KEY/ANON_KEY from 'supabase status'." >&2 exit 1 fi export SUPABASE_TEST_URL SUPABASE_TEST_SERVICE_ROLE_KEY SUPABASE_TEST_ANON_KEY +if ! command -v psql >/dev/null 2>&1; then + echo "psql not found. Install PostgreSQL's client tools before running stack tests." >&2 + exit 1 +fi + +# A newly started local stack contains Supabase's system schemas but none of +# Mike's application tables. Initialize only an empty stack: silently resetting +# or modifying an existing application database would be surprising. +PROJECTS_TABLE="$( + psql "$SUPABASE_TEST_DB_URL" -XAtq \ + -c "select to_regclass('public.projects');" +)" +if [[ "$PROJECTS_TABLE" != "projects" ]]; then + echo "Mike schema not found; loading $SCHEMA_FILE" + psql "$SUPABASE_TEST_DB_URL" -X \ + --set ON_ERROR_STOP=1 \ + --file "$SCHEMA_FILE" +fi + echo "Running stack integration tests against $SUPABASE_TEST_URL" +cd "$BACKEND_DIR" exec npx vitest run \ src/__tests__/integration/stack.supabase.test.ts \ src/__tests__/integration/access.supabase.test.ts \ diff --git a/backend/src/__tests__/integration/access.supabase.test.ts b/backend/src/__tests__/integration/access.supabase.test.ts index 65b9244..edfea6d 100644 --- a/backend/src/__tests__/integration/access.supabase.test.ts +++ b/backend/src/__tests__/integration/access.supabase.test.ts @@ -28,7 +28,7 @@ maybeDescribe("Supabase access integration", () => { const privateDocId = crypto.randomUUID(); try { - await admin.from("projects").insert([ + const projectsInsert = await admin.from("projects").insert([ { id: sharedProjectId, user_id: ownerId, @@ -42,9 +42,16 @@ maybeDescribe("Supabase access integration", () => { shared_with: [], }, ]); + if (projectsInsert.error) { + throw new Error( + `Could not seed projects: ${projectsInsert.error.message}`, + { cause: projectsInsert.error }, + ); + } + // filename/file_type live on document_versions in this schema — // the documents rows only need identity + ownership columns. - await admin.from("documents").insert([ + const documentsInsert = await admin.from("documents").insert([ { id: sharedDocId, user_id: ownerId, @@ -56,6 +63,12 @@ maybeDescribe("Supabase access integration", () => { project_id: privateProjectId, }, ]); + if (documentsInsert.error) { + throw new Error( + `Could not seed documents: ${documentsInsert.error.message}`, + { cause: documentsInsert.error }, + ); + } await expect( listAccessibleProjectIds( From b2dbb39c62f93c8a1afd3079be2179e3a81fab9a Mon Sep 17 00:00:00 2001 From: willchen96 Date: Thu, 23 Jul 2026 21:37:11 +0800 Subject: [PATCH 4/4] narrow service role schema grants --- backend/schema.sql | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/backend/schema.sql b/backend/schema.sql index 48957b8..6819473 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -887,8 +887,12 @@ revoke all on public.courtlistener_citation_index from anon, authenticated; revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated; -- Tables created by this file are owned by the database bootstrap role. The --- backend connects as service_role, so grant it the privileges that the direct --- browser roles above intentionally do not have. RLS is still enabled as --- defense in depth; service_role bypasses it for the backend data path. -grant all privileges on all tables in schema public to service_role; -grant all privileges on all sequences in schema public to service_role; +-- backend connects as service_role, so grant it only the data privileges that +-- the direct browser roles above intentionally do not have. RLS is still +-- enabled as defense in depth; service_role bypasses it for the backend path. +grant select, insert, update, delete + on all tables in schema public + to service_role; +grant usage, select + on all sequences in schema public + to service_role;