mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
4039b94980
commit
45a4f7508c
14 changed files with 2949 additions and 164 deletions
292
backend/package-lock.json
generated
292
backend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
38
backend/scripts/test-stack.sh
Executable file
38
backend/scripts/test-stack.sh
Executable file
|
|
@ -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 \
|
||||
"$@"
|
||||
84
backend/src/__tests__/integration/access.supabase.test.ts
Normal file
84
backend/src/__tests__/integration/access.supabase.test.ts
Normal file
|
|
@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
173
backend/src/__tests__/integration/chat.routes.test.ts
Normal file
173
backend/src/__tests__/integration/chat.routes.test.ts
Normal file
|
|
@ -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<string, unknown> = {};
|
||||
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<string, unknown> },
|
||||
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<typeof import("../../lib/chat")>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
108
backend/src/__tests__/integration/documentsUpload.routes.test.ts
Normal file
108
backend/src/__tests__/integration/documentsUpload.routes.test.ts
Normal file
|
|
@ -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<string, unknown> = {};
|
||||
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<string, unknown> },
|
||||
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<typeof import("../../lib/storage")>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
80
backend/src/__tests__/integration/health.test.ts
Normal file
80
backend/src/__tests__/integration/health.test.ts
Normal file
|
|
@ -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<string, unknown> = {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
149
backend/src/__tests__/integration/projectChat.routes.test.ts
Normal file
149
backend/src/__tests__/integration/projectChat.routes.test.ts
Normal file
|
|
@ -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<string, unknown> = {};
|
||||
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<string, unknown> },
|
||||
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<typeof import("../../lib/chat")>();
|
||||
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]");
|
||||
});
|
||||
});
|
||||
415
backend/src/__tests__/integration/projects.routes.test.ts
Normal file
415
backend/src/__tests__/integration/projects.routes.test.ts
Normal file
|
|
@ -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<string, QueryResult>;
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> },
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
146
backend/src/__tests__/integration/stack.supabase.test.ts
Normal file
146
backend/src/__tests__/integration/stack.supabase.test.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
713
backend/src/__tests__/integration/tabular.routes.test.ts
Normal file
713
backend/src/__tests__/integration/tabular.routes.test.ts
Normal file
|
|
@ -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<string, QueryResult>;
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> },
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
588
backend/src/__tests__/integration/user.routes.test.ts
Normal file
588
backend/src/__tests__/integration/user.routes.test.ts
Normal file
|
|
@ -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<string, QueryResult>;
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> },
|
||||
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<string, unknown> = {}) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
161
backend/src/app.ts
Normal file
161
backend/src/app.ts
Normal file
|
|
@ -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 }));
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue