diff --git a/.gitignore b/.gitignore index 6f8ff6d..403943d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,9 @@ backend/dist/ # E2E Testing e2e/node_modules/ e2e/test-results/ +e2e/test-results-user/ e2e/playwright-report/ +e2e/playwright-report-user/ e2e/.playwright/ # Temporary files diff --git a/AGENTS.md b/AGENTS.md index 8658582..ae58e6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,7 @@ ExcaliDash/ **Backend (.env):** ```bash -DATABASE_URL="file:./prisma/dev.db" +DATABASE_URL="file:./dev.db" PORT=8000 NODE_ENV=development ``` diff --git a/README.md b/README.md index c20b494..0c849c8 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a sp # Installation > [!CAUTION] -> NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization), they are inadequate for public deployment. Do not expose any ports. Currently lacking CSRF. +> NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization), they are inadequate for public deployment. Do not expose any ports. > [!CAUTION] > ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron). diff --git a/backend/package-lock.json b/backend/package-lock.json index edd4658..9735739 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@prisma/client": "^5.22.0", "@types/archiver": "^7.0.0", - "@types/jsdom": "^27.0.0", + "@types/jsdom": "^21.1.7", "@types/multer": "^2.0.0", "@types/socket.io": "^3.0.1", "archiver": "^7.0.1", @@ -20,7 +20,7 @@ "dompurify": "^3.3.0", "dotenv": "^17.2.3", "express": "^5.1.0", - "jsdom": "^27.2.0", + "jsdom": "^22.1.0", "multer": "^2.0.2", "prisma": "^5.22.0", "socket.io": "^4.8.1", @@ -38,62 +38,6 @@ "vitest": "^4.0.15" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.24", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.24.tgz", - "integrity": "sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg==", - "license": "MIT" - }, - "node_modules/@asamuzakjp/css-color": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.0.tgz", - "integrity": "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==", - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.2" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.4", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz", - "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==", - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.2" - } - }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "license": "MIT" - }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -107,137 +51,6 @@ "node": ">=12" } }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.17", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.17.tgz", - "integrity": "sha512-LCC++2h8pLUSPY+EsZmrrJ1EOUu+5iClpEiDhhdw3zRJpPbABML/N5lmRuBHjxtKm9VnRcsUzioyD0sekFMF0A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1142,6 +955,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -1269,9 +1091,9 @@ "license": "MIT" }, "node_modules/@types/jsdom": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz", - "integrity": "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==", + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "license": "MIT", "dependencies": { "@types/node": "*", @@ -1306,7 +1128,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1519,6 +1340,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "license": "BSD-3-Clause" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -1571,12 +1399,15 @@ } }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", + "dependencies": { + "debug": "4" + }, "engines": { - "node": ">= 14" + "node": ">= 6.0.0" } }, "node_modules/ansi-regex": { @@ -1725,7 +1556,6 @@ "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/b4a": { @@ -1805,15 +1635,6 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2086,7 +1907,6 @@ "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" @@ -2287,44 +2107,30 @@ "node": ">= 8" } }, - "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "license": "MIT", - "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/cssstyle": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz", - "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", + "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", - "css-tree": "^3.1.0" + "rrweb-cssom": "^0.6.0" }, "engines": { - "node": ">=20" + "node": ">=14" } }, "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", + "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.0" }, "engines": { - "node": ">=20" + "node": ">=14" } }, "node_modules/debug": { @@ -2378,7 +2184,6 @@ "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" @@ -2423,6 +2228,19 @@ "node": ">=0.3.1" } }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dompurify": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", @@ -2636,7 +2454,6 @@ "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", @@ -2873,7 +2690,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -2890,7 +2706,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -2900,7 +2715,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -3118,7 +2932,6 @@ "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" @@ -3143,15 +2956,15 @@ } }, "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", "license": "MIT", "dependencies": { - "whatwg-encoding": "^3.1.1" + "whatwg-encoding": "^2.0.0" }, "engines": { - "node": ">=18" + "node": ">=12" } }, "node_modules/http-errors": { @@ -3175,29 +2988,30 @@ } }, "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", "license": "MIT", "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", + "@tootallnate/once": "2", + "agent-base": "6", "debug": "4" }, "engines": { - "node": ">= 14" + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" } }, "node_modules/iconv-lite": { @@ -3367,37 +3181,40 @@ } }, "node_modules/jsdom": { - "version": "27.2.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", - "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", + "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.23", - "@asamuzakjp/dom-selector": "^6.7.4", - "cssstyle": "^5.3.3", - "data-urls": "^6.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", + "abab": "^2.0.6", + "cssstyle": "^3.0.0", + "data-urls": "^4.0.0", + "decimal.js": "^10.4.3", + "domexception": "^4.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", + "nwsapi": "^2.2.4", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", - "xml-name-validator": "^5.0.0" + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.1", + "ws": "^8.13.0", + "xml-name-validator": "^4.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=16" }, "peerDependencies": { - "canvas": "^3.0.0" + "canvas": "^2.5.0" }, "peerDependenciesMeta": { "canvas": { @@ -3405,18 +3222,6 @@ } } }, - "node_modules/jsdom/node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/jsdom/node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -3518,12 +3323,6 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "license": "CC0-1.0" - }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -3805,6 +3604,12 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4008,7 +3813,6 @@ "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -4050,6 +3854,18 @@ "node": ">= 0.10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -4091,6 +3907,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4203,14 +4025,11 @@ "node": ">=8.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" }, "node_modules/rollup": { "version": "4.53.3", @@ -4270,6 +4089,12 @@ "node": ">= 18" } }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4672,6 +4497,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -4993,7 +4819,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5011,24 +4836,6 @@ "node": ">=14.0.0" } }, - "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.19" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", - "license": "MIT" - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5062,27 +4869,30 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", "license": "BSD-3-Clause", "dependencies": { - "tldts": "^7.0.5" + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" }, "engines": { - "node": ">=16" + "node": ">=6" } }, "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", "license": "MIT", "dependencies": { - "punycode": "^2.3.1" + "punycode": "^2.3.0" }, "engines": { - "node": ">=20" + "node": ">=14" } }, "node_modules/ts-node": { @@ -5167,7 +4977,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5189,6 +4998,15 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5198,6 +5016,16 @@ "node": ">= 0.8" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5226,7 +5054,6 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5320,7 +5147,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5420,58 +5246,58 @@ } }, "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", "license": "MIT", "dependencies": { - "xml-name-validator": "^5.0.0" + "xml-name-validator": "^4.0.0" }, "engines": { - "node": ">=18" + "node": ">=14" } }, "node_modules/webidl-conversions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", - "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "license": "BSD-2-Clause", "engines": { - "node": ">=20" + "node": ">=12" } }, "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" }, "engines": { - "node": ">=18" + "node": ">=12" } }, "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" } }, "node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", "license": "MIT", "dependencies": { - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=20" + "node": ">=14" } }, "node_modules/which": { @@ -5625,12 +5451,12 @@ } }, "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=12" } }, "node_modules/xmlchars": { diff --git a/backend/package.json b/backend/package.json index 3eecefe..8077adf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,7 +16,7 @@ "dependencies": { "@prisma/client": "^5.22.0", "@types/archiver": "^7.0.0", - "@types/jsdom": "^27.0.0", + "@types/jsdom": "^21.1.7", "@types/multer": "^2.0.0", "@types/socket.io": "^3.0.1", "archiver": "^7.0.1", @@ -25,7 +25,7 @@ "dompurify": "^3.3.0", "dotenv": "^17.2.3", "express": "^5.1.0", - "jsdom": "^27.2.0", + "jsdom": "^22.1.0", "multer": "^2.0.2", "prisma": "^5.22.0", "socket.io": "^4.8.1", @@ -42,4 +42,4 @@ "typescript": "^5.9.3", "vitest": "^4.0.15" } -} +} \ No newline at end of file diff --git a/backend/src/__tests__/csrf.integration.ts b/backend/src/__tests__/csrf.integration.ts new file mode 100644 index 0000000..a06d79e --- /dev/null +++ b/backend/src/__tests__/csrf.integration.ts @@ -0,0 +1,168 @@ +/** + * CSRF Tests - Horizontal Scaling (K8s) Validation + * + * PR #20 review concern: + * "Worried that in memory token store might not work on horizontal scaling" + * + * Fix: + * - CSRF tokens are now stateless and HMAC-signed using a shared `CSRF_SECRET`. + * - Any pod can validate any token as long as all pods share the same secret. + * + * These tests prove: + * - Tokens validate correctly for the issuing client id + * - Tokens do NOT validate for a different client id + * - Tokens expire after 24 hours + * - Tokens validate across separate module instances (simulated pods) + */ + +import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; + +const SHARED_SECRET = "test-shared-csrf-secret"; + +beforeAll(() => { + // Must be shared across instances/pods for horizontal scaling. + process.env.CSRF_SECRET = SHARED_SECRET; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("CSRF - stateless HMAC tokens", () => { + it("creates a token in payload.signature format and validates for same client id", async () => { + const { createCsrfToken, validateCsrfToken } = await import("../security"); + + const clientId = "test-client-1"; + const token = createCsrfToken(clientId); + + expect(typeof token).toBe("string"); + // base64url(payload).base64url(signature) + expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); + expect(validateCsrfToken(clientId, token)).toBe(true); + }); + + it("rejects validation for a different client id (token binding)", async () => { + const { createCsrfToken, validateCsrfToken } = await import("../security"); + + const token = createCsrfToken("client-a"); + expect(validateCsrfToken("client-b", token)).toBe(false); + }); + + it("rejects malformed tokens", async () => { + const { validateCsrfToken } = await import("../security"); + + expect(validateCsrfToken("client", "not-a-token")).toBe(false); + expect(validateCsrfToken("client", "a.b.c")).toBe(false); + expect(validateCsrfToken("client", "")).toBe(false); + }); + + it("revokeCsrfToken is a no-op for stateless tokens (does not break callers)", async () => { + const { createCsrfToken, validateCsrfToken, revokeCsrfToken } = await import( + "../security" + ); + + const clientId = "client-revoke"; + const token = createCsrfToken(clientId); + + expect(validateCsrfToken(clientId, token)).toBe(true); + revokeCsrfToken(clientId); + // Stateless token remains valid until expiry + expect(validateCsrfToken(clientId, token)).toBe(true); + }); + + it("expires tokens after 24 hours", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z")); + + const { createCsrfToken, validateCsrfToken } = await import("../security"); + + const clientId = "client-expiry"; + const token = createCsrfToken(clientId); + expect(validateCsrfToken(clientId, token)).toBe(true); + + // 24h + 1ms later + vi.setSystemTime(new Date("2025-01-02T00:00:00.001Z")); + expect(validateCsrfToken(clientId, token)).toBe(false); + }); +}); + +describe("CSRF - horizontal scaling (simulated pods)", () => { + it("validates across module instances (pod A issues, pod B validates)", async () => { + const clientId = "user-123"; + + vi.resetModules(); + const podA = await import("../security"); + const token = podA.createCsrfToken(clientId); + + // Simulate a different pod (new Node.js process / fresh module state) + vi.resetModules(); + const podB = await import("../security"); + + expect(podB.validateCsrfToken(clientId, token)).toBe(true); + }); + + it("has 0% failure rate under round-robin validation across 3 pods", async () => { + const clientId = "user-round-robin"; + + const pods: Array<{ + createCsrfToken: (clientId: string) => string; + validateCsrfToken: (clientId: string, token: string) => boolean; + }> = []; + + for (let i = 0; i < 3; i++) { + vi.resetModules(); + pods.push(await import("../security")); + } + + // Token issued on one pod + const token = pods[0].createCsrfToken(clientId); + + // Validate on alternating pods (simulates a non-sticky load balancer) + const attempts = 60; + let failures = 0; + + for (let i = 0; i < attempts; i++) { + const pod = pods[i % pods.length]; + if (!pod.validateCsrfToken(clientId, token)) failures++; + } + + expect(failures).toBe(0); + }); +}); + +describe("CSRF - referer origin parsing", () => { + it("extracts exact origin from a referer URL", async () => { + const { getOriginFromReferer } = await import("../security"); + + expect(getOriginFromReferer("https://example.com/path?x=1")).toBe( + "https://example.com" + ); + expect(getOriginFromReferer("http://localhost:5173/some/page")).toBe( + "http://localhost:5173" + ); + }); + + it("does not allow prefix tricks (origin must be parsed)", async () => { + const { getOriginFromReferer } = await import("../security"); + + expect( + getOriginFromReferer("https://example.com.evil.com/anything") + ).toBe("https://example.com.evil.com"); + + // `startsWith("https://example.com")` would incorrectly allow this. + expect(getOriginFromReferer("https://example.com@evil.com/anything")).toBe( + "https://evil.com" + ); + }); + + it("returns null for invalid or non-http(s) referers", async () => { + const { getOriginFromReferer } = await import("../security"); + + expect(getOriginFromReferer("")).toBeNull(); + expect(getOriginFromReferer("not a url")).toBeNull(); + expect(getOriginFromReferer("file:///etc/passwd")).toBeNull(); + expect(getOriginFromReferer(null)).toBeNull(); + }); +}); + + diff --git a/backend/src/index.ts b/backend/src/index.ts index a171f2d..b354846 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -18,6 +18,10 @@ import { sanitizeSvg, elementSchema, appStateSchema, + createCsrfToken, + validateCsrfToken, + getCsrfTokenHeader, + getOriginFromReferer, } from "./security"; dotenv.config(); @@ -34,9 +38,22 @@ const resolveDatabaseUrl = (rawUrl?: string) => { } const filePath = rawUrl.replace(/^file:/, ""); + + // Prisma treats relative SQLite paths as relative to the schema directory + // (i.e. `backend/prisma/schema.prisma`). Historically this project used + // `file:./prisma/dev.db`, which Prisma interprets as `prisma/prisma/dev.db`. + // To keep runtime and migrations aligned: + // - Prefer resolving relative paths against `backend/prisma` + // - But if the path already includes a leading `prisma/`, resolve from repo root + const prismaDir = path.resolve(backendRoot, "prisma"); + const normalizedRelative = filePath.replace(/^\.\/?/, ""); + const hasLeadingPrismaDir = + normalizedRelative === "prisma" || + normalizedRelative.startsWith("prisma/"); + const absolutePath = path.isAbsolute(filePath) ? filePath - : path.resolve(backendRoot, filePath); + : path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative); return `file:${absolutePath}`; }; @@ -63,11 +80,15 @@ const normalizeOrigins = (rawOrigins?: string | null): string[] => { const ensureProtocol = (origin: string) => /^https?:\/\//i.test(origin) ? origin : `http://${origin}`; + const removeTrailingSlash = (origin: string) => + origin.endsWith("/") ? origin.slice(0, -1) : origin; + const parsed = rawOrigins .split(",") .map((origin) => origin.trim()) .filter((origin) => origin.length > 0) - .map(ensureProtocol); + .map(ensureProtocol) + .map(removeTrailingSlash); return parsed.length > 0 ? parsed : [fallback]; }; @@ -211,6 +232,8 @@ app.use( cors({ origin: allowedOrigins, credentials: true, + allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"], + exposedHeaders: ["x-csrf-token"], }) ); app.use(express.json({ limit: "50mb" })); @@ -244,12 +267,12 @@ app.use((req, res, next) => { res.setHeader( "Content-Security-Policy", "default-src 'self'; " + - "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " + - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + - "font-src 'self' https://fonts.gstatic.com; " + - "img-src 'self' data: blob: https:; " + - "connect-src 'self' ws: wss:; " + - "frame-ancestors 'none';" + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " + + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + + "font-src 'self' https://fonts.gstatic.com; " + + "img-src 'self' data: blob: https:; " + + "connect-src 'self' ws: wss:; " + + "frame-ancestors 'none';" ); next(); @@ -296,6 +319,132 @@ app.use((req, res, next) => { next(); }); +// CSRF Protection Middleware +// Generates a unique client ID based on IP and User-Agent for token association +const getClientId = (req: express.Request): string => { + const ip = req.ip || req.connection.remoteAddress || "unknown"; + const userAgent = req.headers["user-agent"] || "unknown"; + // Create a simple hash for client identification + // In production, you might use a session ID instead + return `${ip}:${userAgent}`.slice(0, 256); +}; + +// Rate limiter specifically for CSRF token generation to prevent store exhaustion +const csrfRateLimit = new Map(); +const CSRF_RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute +const CSRF_MAX_REQUESTS = (() => { + const parsed = Number(process.env.CSRF_MAX_REQUESTS); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 60; // 1 per second average + } + return parsed; +})(); + +// CSRF token endpoint - clients should call this to get a token +app.get("/csrf-token", (req, res) => { + const ip = req.ip || req.connection.remoteAddress || "unknown"; + const now = Date.now(); + const clientLimit = csrfRateLimit.get(ip); + + if (clientLimit && now < clientLimit.resetTime) { + if (clientLimit.count >= CSRF_MAX_REQUESTS) { + return res.status(429).json({ + error: "Rate limit exceeded", + message: "Too many CSRF token requests", + }); + } + clientLimit.count++; + } else { + csrfRateLimit.set(ip, { count: 1, resetTime: now + CSRF_RATE_LIMIT_WINDOW }); + } + + // Cleanup old rate limit entries occasionally + if (Math.random() < 0.01) { + for (const [key, data] of csrfRateLimit.entries()) { + if (now > data.resetTime) csrfRateLimit.delete(key); + } + } + + const clientId = getClientId(req); + const token = createCsrfToken(clientId); + + res.json({ + token, + header: getCsrfTokenHeader() + }); +}); + +// CSRF validation middleware for state-changing requests +const csrfProtectionMiddleware = ( + req: express.Request, + res: express.Response, + next: express.NextFunction +) => { + // Skip CSRF validation for safe methods (GET, HEAD, OPTIONS) + const safeMethods = ["GET", "HEAD", "OPTIONS"]; + if (safeMethods.includes(req.method)) { + return next(); + } + + // Skip CSRF for the CSRF token endpoint itself + if (req.path === "/csrf-token") { + return next(); + } + + // Origin/Referer check for defense in depth + const origin = req.headers["origin"]; + const referer = req.headers["referer"]; + + // If Origin is present, it must match allowed origins + const originValue = Array.isArray(origin) ? origin[0] : origin; + const refererValue = Array.isArray(referer) ? referer[0] : referer; + + if (originValue) { + if (!allowedOrigins.includes(originValue)) { + return res.status(403).json({ + error: "CSRF origin mismatch", + message: "Origin not allowed", + }); + } + } else if (refererValue) { + // If no Origin but Referer exists, validate its *origin* (avoid prefix bypass) + const refererOrigin = getOriginFromReferer(refererValue); + if (!refererOrigin || !allowedOrigins.includes(refererOrigin)) { + return res.status(403).json({ + error: "CSRF referer mismatch", + message: "Referer not allowed", + }); + } + } + // Note: If neither Origin nor Referer is present, we proceed to token check. + // Some legitimate clients/proxies might strip these, so we don't block strictly on their absence, + // but relying on the token is the primary defense. + + const clientId = getClientId(req); + const headerName = getCsrfTokenHeader(); + const tokenHeader = req.headers[headerName]; + const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader; + + if (!token) { + return res.status(403).json({ + error: "CSRF token missing", + message: `Missing ${headerName} header`, + }); + } + + if (!validateCsrfToken(clientId, token)) { + return res.status(403).json({ + error: "CSRF token invalid", + message: "Invalid or expired CSRF token. Please refresh and try again.", + }); + } + + next(); +}; + +// Apply CSRF protection to all routes +app.use(csrfProtectionMiddleware); + const filesFieldSchema = z .union([z.record(z.string(), z.any()), z.null()]) .optional() @@ -922,8 +1071,7 @@ app.get("/export", async (req, res) => { res.setHeader("Content-Type", "application/octet-stream"); res.setHeader( "Content-Disposition", - `attachment; filename="excalidash-db-${ - new Date().toISOString().split("T")[0] + `attachment; filename="excalidash-db-${new Date().toISOString().split("T")[0] }.${extension}"` ); @@ -946,8 +1094,7 @@ app.get("/export/json", async (req, res) => { res.setHeader("Content-Type", "application/zip"); res.setHeader( "Content-Disposition", - `attachment; filename="excalidraw-drawings-${ - new Date().toISOString().split("T")[0] + `attachment; filename="excalidraw-drawings-${new Date().toISOString().split("T")[0] }.zip"` ); @@ -1012,8 +1159,8 @@ Total Drawings: ${drawings.length} Collections: ${Object.entries(drawingsByCollection) - .map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`) - .join("\n")} + .map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`) + .join("\n")} `; archive.append(readmeContent, { name: "README.txt" }); @@ -1085,7 +1232,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => { try { await fsPromises.access(dbPath); await fsPromises.copyFile(dbPath, backupPath); - } catch {} + } catch { } await moveFile(stagedPath, dbPath); } catch (error) { diff --git a/backend/src/security.ts b/backend/src/security.ts index 389538d..e1dfb50 100644 --- a/backend/src/security.ts +++ b/backend/src/security.ts @@ -1,6 +1,10 @@ +/** + * Security utilities for XSS prevention, data sanitization, and CSRF protection + */ import { z } from "zod"; import DOMPurify from "dompurify"; import { JSDOM } from "jsdom"; +import crypto from "crypto"; // Create a DOM environment for DOMPurify (Node.js compatibility) const window = new JSDOM("").window; @@ -523,3 +527,187 @@ export const validateImportedDrawing = (data: any): boolean => { return false; } }; + +// ============================================================================ +// CSRF Protection +// ============================================================================ + +const CSRF_TOKEN_LENGTH = 32; +const CSRF_TOKEN_HEADER = "x-csrf-token"; +const CSRF_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours +const CSRF_TOKEN_FUTURE_SKEW_MS = 5 * 60 * 1000; // 5 minutes clock skew tolerance +const CSRF_NONCE_BYTES = 16; +const CSRF_TOKEN_MAX_LENGTH = 2048; // sanity limit against abuse + +/** + * IMPORTANT (Horizontal Scaling / K8s) + * ----------------------------------- + * CSRF tokens must validate across multiple stateless instances. + * + * The prior in-memory Map-based token store breaks under horizontal scaling + * because each pod has its own memory. This implementation is stateless: + * + * - Token payload: { ts, nonce } + * - Signature: HMAC_SHA256(secret, `${clientId}|${ts}|${nonce}`) + * + * As long as all pods share the same `CSRF_SECRET`, any pod can validate + * any token without shared state (works on Kubernetes). + */ + +let cachedCsrfSecret: Buffer | null = null; +const getCsrfSecret = (): Buffer => { + if (cachedCsrfSecret) return cachedCsrfSecret; + + const secretFromEnv = process.env.CSRF_SECRET; + if (secretFromEnv && secretFromEnv.trim().length > 0) { + cachedCsrfSecret = Buffer.from(secretFromEnv, "utf8"); + return cachedCsrfSecret; + } + + // If not configured, generate an ephemeral secret for this process. + // This keeps single-instance deployments working out of the box, but: + // - Horizontal scaling will BREAK unless CSRF_SECRET is set and shared. + cachedCsrfSecret = crypto.randomBytes(32); + const envLabel = process.env.NODE_ENV ? ` (${process.env.NODE_ENV})` : ""; + console.warn( + `[security] CSRF_SECRET is not set${envLabel}. Using an ephemeral per-process secret. ` + + "For horizontal scaling (k8s), set CSRF_SECRET to the same value on all instances." + ); + return cachedCsrfSecret; +}; + +const base64UrlEncode = (input: Buffer | string): string => { + const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input; + return buf + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +}; + +const base64UrlDecode = (input: string): Buffer => { + const normalized = input.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + return Buffer.from(padded, "base64"); +}; + +type CsrfTokenPayload = { + /** Issued-at timestamp (ms since epoch) */ + ts: number; + /** Random nonce (base64url) */ + nonce: string; +}; + +const signCsrfToken = (clientId: string, payload: CsrfTokenPayload): Buffer => { + const secret = getCsrfSecret(); + const data = `${clientId}|${payload.ts}|${payload.nonce}`; + return crypto.createHmac("sha256", secret).update(data, "utf8").digest(); +}; + +/** + * Generate a cryptographically secure CSRF token + */ +export const generateCsrfToken = (): string => { + return crypto.randomBytes(CSRF_TOKEN_LENGTH).toString("hex"); +}; + +/** + * Create and store a new CSRF token for a client + * Returns the token to be sent to the client + */ +export const createCsrfToken = (clientId: string): string => { + const payload: CsrfTokenPayload = { + ts: Date.now(), + nonce: base64UrlEncode(crypto.randomBytes(CSRF_NONCE_BYTES)), + }; + + const payloadJson = JSON.stringify(payload); + const payloadB64 = base64UrlEncode(payloadJson); + const sigB64 = base64UrlEncode(signCsrfToken(clientId, payload)); + + return `${payloadB64}.${sigB64}`; +}; + +/** + * Validate a CSRF token for a client + * Uses timing-safe comparison to prevent timing attacks + */ +export const validateCsrfToken = (clientId: string, token: string): boolean => { + if (!token || typeof token !== "string") { + return false; + } + + if (token.length > CSRF_TOKEN_MAX_LENGTH) { + return false; + } + + try { + const parts = token.split("."); + if (parts.length !== 2) return false; + + const [payloadB64, sigB64] = parts; + const payloadJson = base64UrlDecode(payloadB64).toString("utf8"); + const payload = JSON.parse(payloadJson) as Partial; + + if ( + typeof payload.ts !== "number" || + !Number.isFinite(payload.ts) || + typeof payload.nonce !== "string" || + payload.nonce.length < 8 + ) { + return false; + } + + const now = Date.now(); + // Expiry check + if (now - payload.ts > CSRF_TOKEN_EXPIRY_MS) return false; + // Future skew check (clock mismatch) + if (payload.ts - now > CSRF_TOKEN_FUTURE_SKEW_MS) return false; + + const expectedSig = signCsrfToken(clientId, { + ts: payload.ts, + nonce: payload.nonce, + }); + + const providedSig = base64UrlDecode(sigB64); + if (providedSig.length !== expectedSig.length) return false; + + return crypto.timingSafeEqual(providedSig, expectedSig); + } catch { + return false; + } +}; + +/** + * Revoke a CSRF token (e.g., on logout or token refresh) + */ +export const revokeCsrfToken = (clientId: string): void => { + // Stateless CSRF tokens cannot be selectively revoked without shared state. + // If revocation is required, implement token blacklisting in a shared store + // (e.g., Redis) or rotate CSRF_SECRET. + void clientId; +}; + +/** + * Get the CSRF token header name + */ +export const getCsrfTokenHeader = (): string => { + return CSRF_TOKEN_HEADER; +}; + +export const getOriginFromReferer = (referer: unknown): string | null => { + if (typeof referer !== "string" || referer.trim().length === 0) { + return null; + } + + try { + const url = new URL(referer); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + + return `${url.protocol}//${url.host}`; + } catch { + return null; + } +}; diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 18be842..082b7c5 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -1,6 +1,4 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ +export default { test: { globals: true, environment: "node", @@ -20,4 +18,4 @@ export default defineConfig({ }, }, }, -}); +}; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e128be9..6394181 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -6,6 +6,8 @@ services: - DATABASE_URL=file:/app/prisma/dev.db - PORT=8000 - NODE_ENV=production + # Required for horizontal scaling (k8s): must be the same across all instances + - CSRF_SECRET=${CSRF_SECRET} volumes: - backend-data:/app/prisma networks: diff --git a/docker-compose.yml b/docker-compose.yml index 2c76f10..7a42374 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: - DATABASE_URL=file:/app/prisma/dev.db - PORT=8000 - NODE_ENV=production + # Required for horizontal scaling (k8s): must be the same across all instances + - CSRF_SECRET=${CSRF_SECRET} volumes: - backend-data:/app/prisma networks: diff --git a/e2e/Dockerfile.playwright b/e2e/Dockerfile.playwright index e18e0c4..0d6ea0b 100644 --- a/e2e/Dockerfile.playwright +++ b/e2e/Dockerfile.playwright @@ -1,5 +1,5 @@ # Playwright E2E Test Runner -FROM mcr.microsoft.com/playwright:v1.52.0-noble +FROM mcr.microsoft.com/playwright:v1.57.0-noble WORKDIR /app diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml index f5b0828..7d88fda 100644 --- a/e2e/docker-compose.e2e.yml +++ b/e2e/docker-compose.e2e.yml @@ -17,14 +17,18 @@ services: context: ../backend dockerfile: Dockerfile environment: - - DATABASE_URL=file:./prisma/e2e-test.db + # Use an absolute sqlite path so Prisma CLI + the running app always point + # at the same DB file (avoids schema being applied to a different relative path). + - DATABASE_URL=file:/app/prisma/e2e-test.db - PORT=8000 - NODE_ENV=test - - FRONTEND_URL=http://frontend:80,http://localhost:5173 + # Include both with and without :80 because browsers omit default ports in Origin. + - FRONTEND_URL=http://frontend,http://frontend:80,http://localhost:5173 ports: - "8000:8000" healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/health"] + # Use IPv4 loopback explicitly to avoid IPv6 localhost resolution issues. + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8000/health"] interval: 5s timeout: 5s retries: 10 @@ -35,17 +39,18 @@ services: # Frontend web server frontend: build: - context: ../frontend - dockerfile: Dockerfile - args: - - VITE_API_URL=http://backend:8000 + # Use the repo root as build context because `frontend/Dockerfile` expects + # `frontend/...` paths (same as production `docker-compose.yml`). + context: .. + dockerfile: frontend/Dockerfile ports: - "5173:80" depends_on: backend: condition: service_healthy healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:80"] + # Use IPv4 loopback explicitly to avoid IPv6 localhost resolution issues. + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80"] interval: 5s timeout: 5s retries: 10 diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 308df1b..2f8c17b 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -17,49 +17,56 @@ const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`; */ export default defineConfig({ testDir: "./tests", - + // Run tests in parallel fullyParallel: true, - + // Fail the build on test.only() in CI forbidOnly: !!process.env.CI, - + // Retry on CI only retries: process.env.CI ? 2 : 0, - + // Limit parallel workers in CI workers: process.env.CI ? 1 : undefined, - + // Reporter configuration reporter: [ ["list"], - ["html", { outputFolder: "playwright-report" }], + [ + "html", + { + // Useful when a previous Docker run produced root-owned artifacts. + // Allows local runs to redirect output without editing the config. + outputFolder: process.env.PLAYWRIGHT_REPORT_DIR || "playwright-report", + }, + ], ], - + // Output folder for test artifacts - outputDir: "test-results", - + outputDir: process.env.PLAYWRIGHT_OUTPUT_DIR || "test-results", + // Global timeout for each test timeout: 60000, - + // Expect timeout expect: { timeout: 10000, }, - + use: { // Base URL for page.goto() baseURL: FRONTEND_URL, - + // Collect trace on first retry trace: "on-first-retry", - + // Screenshot on failure screenshot: "only-on-failure", - + // Video on failure video: "on-first-retry", - + // Headed mode based on env var headless: process.env.HEADED !== "true", }, @@ -67,7 +74,7 @@ export default defineConfig({ projects: [ { name: "chromium", - use: { + use: { ...devices["Desktop Chrome"], // Viewport for consistent screenshots viewport: { width: 1280, height: 720 }, @@ -85,8 +92,11 @@ export default defineConfig({ stdout: "pipe", stderr: "pipe", env: { - DATABASE_URL: "file:./prisma/dev.db", + // Prisma resolves relative SQLite paths from the schema directory (backend/prisma). + // Using `file:./dev.db` avoids accidentally creating `prisma/prisma/dev.db`. + DATABASE_URL: "file:./dev.db", FRONTEND_URL, + CSRF_MAX_REQUESTS: "1000", }, }, { diff --git a/e2e/tests/collaboration.spec.ts b/e2e/tests/collaboration.spec.ts index c62574c..1ab8bf8 100644 --- a/e2e/tests/collaboration.spec.ts +++ b/e2e/tests/collaboration.spec.ts @@ -1,6 +1,5 @@ -import { test, expect, type BrowserContext, type Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; import { - API_URL, createDrawing, deleteDrawing, getDrawing, @@ -22,7 +21,7 @@ test.describe("Real-time Collaboration", () => { for (const id of createdDrawingIds) { try { await deleteDrawing(request, id); - } catch (e) { + } catch { // Ignore cleanup errors } } @@ -63,7 +62,7 @@ test.describe("Real-time Collaboration", () => { // At least one page should show the other user const hasCollaborator1 = await collaboratorIndicator1.count(); const hasCollaborator2 = await collaboratorIndicator2.count(); - + // Socket.io presence should eventually show users // This test validates the socket connection works expect(hasCollaborator1 + hasCollaborator2).toBeGreaterThanOrEqual(0); @@ -75,7 +74,7 @@ test.describe("Real-time Collaboration", () => { test("should sync drawing changes between two users", async ({ browser, request }) => { // Create a test drawing - const drawing = await createDrawing(request, { + const drawing = await createDrawing(request, { name: `Collab_Sync_${Date.now()}`, elements: [], }); @@ -121,10 +120,10 @@ test.describe("Real-time Collaboration", () => { // Verify the drawing was saved (via API) const updatedDrawing = await getDrawing(request, drawing.id); - + // The drawing should have elements now const elements = updatedDrawing.elements || []; - + // Element sync happens via socket and periodic save // The test validates the drawing flow works end-to-end expect(elements).toBeDefined(); @@ -136,7 +135,7 @@ test.describe("Real-time Collaboration", () => { test("should persist drawing changes across page reload", async ({ page, request }) => { // Create a test drawing - const drawing = await createDrawing(request, { + const drawing = await createDrawing(request, { name: `Collab_Persist_${Date.now()}`, elements: [], }); @@ -149,7 +148,7 @@ test.describe("Real-time Collaboration", () => { // Draw something - use the interactive canvas layer const canvas = page.locator("canvas.excalidraw__canvas.interactive"); - + // Select rectangle tool await page.keyboard.press("r"); await page.waitForTimeout(200); @@ -157,7 +156,7 @@ test.describe("Real-time Collaboration", () => { // Draw a rectangle - click on the interactive canvas const box = await canvas.boundingBox(); if (!box) throw new Error("Canvas not found"); - + await page.mouse.move(box.x + 150, box.y + 150); await page.mouse.down(); await page.mouse.move(box.x + 350, box.y + 250, { steps: 5 }); @@ -205,7 +204,7 @@ test.describe("Real-time Collaboration", () => { const canvas1 = page1.locator("canvas.excalidraw__canvas.interactive"); const box = await canvas1.boundingBox(); if (!box) throw new Error("Canvas not found"); - + await page1.mouse.move(box.x + 300, box.y + 300); await page1.waitForTimeout(500); await page1.mouse.move(box.x + 400, box.y + 400); @@ -214,7 +213,7 @@ test.describe("Real-time Collaboration", () => { // The cursor position should be broadcasted to page2 // Excalidraw shows collaborator cursors with names // This test validates the socket connection for cursor sync - + // Wait for potential cursor updates await page2.waitForTimeout(1000); diff --git a/e2e/tests/dashboard-workflows.spec.ts b/e2e/tests/dashboard-workflows.spec.ts index 8f1a064..e1749b1 100644 --- a/e2e/tests/dashboard-workflows.spec.ts +++ b/e2e/tests/dashboard-workflows.spec.ts @@ -45,7 +45,7 @@ test.describe("Dashboard Workflows", () => { for (const id of createdDrawingIds) { try { await deleteDrawing(request, id); - } catch (error) { + } catch { // Ignore cleanup failures to keep tests resilient } } @@ -54,7 +54,7 @@ test.describe("Dashboard Workflows", () => { for (const id of createdCollectionIds) { try { await deleteCollection(request, id); - } catch (error) { + } catch { // Ignore cleanup failures to keep tests resilient } } diff --git a/e2e/tests/drag-and-drop.spec.ts b/e2e/tests/drag-and-drop.spec.ts index 2e58802..f1238ce 100644 --- a/e2e/tests/drag-and-drop.spec.ts +++ b/e2e/tests/drag-and-drop.spec.ts @@ -2,7 +2,6 @@ import { test, expect } from "@playwright/test"; import * as path from "path"; import * as fs from "fs"; import { - API_URL, createDrawing, deleteDrawing, listDrawings, @@ -27,7 +26,7 @@ test.describe("Drag and Drop - Collections", () => { for (const id of createdDrawingIds) { try { await deleteDrawing(request, id); - } catch (e) { + } catch { // Ignore cleanup errors } } @@ -36,7 +35,7 @@ test.describe("Drag and Drop - Collections", () => { for (const id of createdCollectionIds) { try { await deleteCollection(request, id); - } catch (e) { + } catch { // Ignore cleanup errors } } @@ -61,7 +60,7 @@ test.describe("Drag and Drop - Collections", () => { // Hover to reveal the collection picker await card.hover(); - + // Click the collection picker button on the card const collectionPicker = card.locator(`[data-testid="collection-picker-${drawing.id}"]`); await collectionPicker.click(); @@ -76,7 +75,7 @@ test.describe("Drag and Drop - Collections", () => { // Navigate to the collection and verify drawing is there await page.getByRole("navigation").getByRole("button", { name: collection.name }).click(); await page.waitForLoadState("networkidle"); - + await expect(card).toBeVisible(); }); @@ -85,9 +84,9 @@ test.describe("Drag and Drop - Collections", () => { const collection = await createCollection(request, `UnorgTest_Collection_${Date.now()}`); createdCollectionIds.push(collection.id); - const drawing = await createDrawing(request, { + const drawing = await createDrawing(request, { name: `UnorgTest_Drawing_${Date.now()}`, - collectionId: collection.id + collectionId: collection.id }); createdDrawingIds.push(drawing.id); @@ -119,7 +118,7 @@ test.describe("Drag and Drop - Collections", () => { // Navigate to Unorganized and verify drawing is there await page.getByRole("navigation").getByRole("button", { name: "Unorganized" }).click(); await page.waitForLoadState("networkidle"); - + await expect(page.locator(`#drawing-card-${drawing.id}`)).toBeVisible(); }); @@ -146,7 +145,7 @@ test.describe("Drag and Drop - Collections", () => { // Select both drawings const card1 = page.locator(`#drawing-card-${drawing1.id}`); const card2 = page.locator(`#drawing-card-${drawing2.id}`); - + await card1.hover(); const toggle1 = card1.locator(`[data-testid="select-drawing-${drawing1.id}"]`); await toggle1.click(); @@ -186,7 +185,7 @@ test.describe("Drag and Drop - File Import", () => { for (const drawing of drawings) { try { await deleteDrawing(request, drawing.id); - } catch (e) { + } catch { // Ignore cleanup errors } } @@ -194,7 +193,7 @@ test.describe("Drag and Drop - File Import", () => { for (const id of createdDrawingIds) { try { await deleteDrawing(request, id); - } catch (e) { + } catch { // Ignore cleanup errors } } @@ -205,7 +204,7 @@ test.describe("Drag and Drop - File Import", () => { // Note: Simulating drag events with files is unreliable in Playwright // because the DataTransfer API has security restrictions. // This test verifies the drop zone UI exists and can be triggered. - + await page.goto("/"); await page.waitForLoadState("networkidle"); @@ -218,13 +217,13 @@ test.describe("Drag and Drop - File Import", () => { try { const dt = new DataTransfer(); dt.items.add(new File(['test'], 'test.excalidraw', { type: 'application/json' })); - + const event = new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: dt, }); - + // Find the main content area and dispatch the event const main = document.querySelector('main'); if (main) { @@ -242,7 +241,7 @@ test.describe("Drag and Drop - File Import", () => { // Check that the drop zone overlay is shown const dropZone = page.getByText("Drop files to import"); const isVisible = await dropZone.isVisible().catch(() => false); - + if (isVisible) { await expect(dropZone).toBeVisible(); } else { @@ -255,7 +254,7 @@ test.describe("Drag and Drop - File Import", () => { } }); - test("should import excalidraw file via file input", async ({ page, request }, testInfo) => { + test("should import excalidraw file via file input", async ({ page }, testInfo) => { await page.goto("/"); await page.waitForLoadState("networkidle"); @@ -275,7 +274,7 @@ test.describe("Drag and Drop - File Import", () => { // Wait for import success modal await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 }); - + // Dismiss the modal await page.getByRole("button", { name: "OK" }).click(); diff --git a/e2e/tests/drawing-crud.spec.ts b/e2e/tests/drawing-crud.spec.ts index 2601a89..d2004fc 100644 --- a/e2e/tests/drawing-crud.spec.ts +++ b/e2e/tests/drawing-crud.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "@playwright/test"; import { + API_URL, createDrawing, deleteDrawing, getDrawing, @@ -24,7 +25,7 @@ test.describe("Drawing Creation", () => { for (const id of createdDrawingIds) { try { await deleteDrawing(request, id); - } catch (e) { + } catch { // Ignore cleanup errors } } @@ -96,7 +97,7 @@ test.describe("Drawing Creation", () => { test("should rename drawing via editor header", async ({ page, request }) => { const originalName = `Rename_Original_${Date.now()}`; const newName = `Rename_Updated_${Date.now()}`; - + const drawing = await createDrawing(request, { name: originalName }); createdDrawingIds.push(drawing.id); @@ -150,7 +151,7 @@ test.describe("Drawing Editing", () => { for (const id of createdDrawingIds) { try { await deleteDrawing(request, id); - } catch (e) { + } catch { // Ignore cleanup errors } } @@ -158,7 +159,7 @@ test.describe("Drawing Editing", () => { }); test("should draw a rectangle on canvas", async ({ page, request }) => { - const drawing = await createDrawing(request, { + const drawing = await createDrawing(request, { name: `Draw_Rect_${Date.now()}`, elements: [], }); @@ -172,19 +173,19 @@ test.describe("Drawing Editing", () => { const canvas = page.locator("canvas.excalidraw__canvas.interactive"); const box = await canvas.boundingBox(); if (!box) throw new Error("Canvas not found"); - + console.log(`Canvas bounding box: x=${box.x}, y=${box.y}, width=${box.width}, height=${box.height}`); - + // Click on the rectangle tool using the label element // Find the label that contains the rectangle radio button const rectangleLabel = page.locator('label:has([data-testid="toolbar-rectangle"])'); await rectangleLabel.click(); await page.waitForTimeout(500); - + // Verify the tool was selected const isRectangleSelectedBefore = await page.locator('[data-testid="toolbar-rectangle"]').isChecked(); console.log("Rectangle tool selected before drawing:", isRectangleSelectedBefore); - + // Draw the rectangle by dragging on the canvas - use center of canvas const centerX = box.x + box.width / 2; const centerY = box.y + box.height / 2; @@ -192,13 +193,13 @@ test.describe("Drawing Editing", () => { const startY = centerY - 75; const endX = centerX + 100; const endY = centerY + 75; - + console.log(`Drawing from (${startX}, ${startY}) to (${endX}, ${endY})`); - + // First click on the canvas to ensure it has focus await page.mouse.click(centerX, centerY); await page.waitForTimeout(200); - + // Now draw the rectangle await page.mouse.move(startX, startY); await page.waitForTimeout(100); @@ -207,10 +208,10 @@ test.describe("Drawing Editing", () => { await page.mouse.move(endX, endY, { steps: 20 }); await page.waitForTimeout(100); await page.mouse.up(); - + // Take a screenshot after drawing await page.screenshot({ path: 'test-results/after-drawing.png' }); - + // Check if Undo button is now enabled (indicating something was drawn) const undoButton = page.locator('button[aria-label="Undo"]'); const isUndoDisabled = await undoButton.getAttribute('disabled'); @@ -231,7 +232,7 @@ test.describe("Drawing Editing", () => { }); test("should draw text on canvas", async ({ page, request }) => { - const drawing = await createDrawing(request, { + const drawing = await createDrawing(request, { name: `Draw_Text_${Date.now()}`, elements: [], }); @@ -245,11 +246,11 @@ test.describe("Drawing Editing", () => { const canvas = page.locator("canvas.excalidraw__canvas.interactive"); const box = await canvas.boundingBox(); if (!box) throw new Error("Canvas not found"); - + // Click to focus the canvas await page.mouse.click(box.x + 100, box.y + 100); await page.waitForTimeout(100); - + // Select text tool using keyboard shortcut (now that canvas is focused) await page.keyboard.press("t"); await page.waitForTimeout(200); @@ -260,7 +261,7 @@ test.describe("Drawing Editing", () => { // Type some text await page.keyboard.type("Hello E2E Test"); - + // Press Escape to finish text editing await page.keyboard.press("Escape"); await page.waitForTimeout(500); @@ -276,7 +277,7 @@ test.describe("Drawing Editing", () => { }); test("should use undo/redo functionality", async ({ page, request }) => { - const drawing = await createDrawing(request, { + const drawing = await createDrawing(request, { name: `Undo_Redo_${Date.now()}`, elements: [], }); @@ -290,10 +291,10 @@ test.describe("Drawing Editing", () => { const canvas = page.locator("canvas.excalidraw__canvas.interactive"); const box = await canvas.boundingBox(); if (!box) throw new Error("Canvas not found"); - + await page.keyboard.press("r"); await page.waitForTimeout(200); - + await page.mouse.move(box.x + 200, box.y + 200); await page.mouse.down(); await page.mouse.move(box.x + 300, box.y + 300, { steps: 5 }); @@ -320,7 +321,7 @@ test.describe("Drawing Deletion", () => { for (const id of createdDrawingIds) { try { await deleteDrawing(request, id); - } catch (e) { + } catch { // Ignore cleanup errors } } @@ -341,7 +342,7 @@ test.describe("Drawing Deletion", () => { // Find the card and select it const card = page.locator(`#drawing-card-${drawing.id}`); await card.hover(); - + const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`); await selectToggle.click(); @@ -360,9 +361,9 @@ test.describe("Drawing Deletion", () => { }); test("should permanently delete drawing from trash", async ({ page, request }) => { - const drawing = await createDrawing(request, { + const drawing = await createDrawing(request, { name: `Perm_Delete_${Date.now()}`, - collectionId: "trash" + collectionId: "trash" }); createdDrawingIds.push(drawing.id); @@ -374,7 +375,7 @@ test.describe("Drawing Deletion", () => { // Select the drawing const card = page.locator(`#drawing-card-${drawing.id}`); await card.hover(); - + const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`); await selectToggle.click(); @@ -388,7 +389,7 @@ test.describe("Drawing Deletion", () => { await expect(card).not.toBeVisible(); // Verify via API that drawing is deleted - const response = await request.get(`http://localhost:8000/drawings/${drawing.id}`); + const response = await request.get(`${API_URL}/drawings/${drawing.id}`); expect(response.status()).toBe(404); // Remove from cleanup list since it's already deleted @@ -409,7 +410,7 @@ test.describe("Drawing Deletion", () => { // Select the drawing const card = page.locator(`#drawing-card-${drawing.id}`); await card.hover(); - + const selectToggle = card.locator(`[data-testid="select-drawing-${drawing.id}"]`); await selectToggle.click(); @@ -422,7 +423,7 @@ test.describe("Drawing Deletion", () => { // Clear search to see all drawings await page.getByPlaceholder("Search drawings...").fill(""); await page.waitForTimeout(500); - + // Search again to find both await page.getByPlaceholder("Search drawings...").fill("Duplicate_Test"); await page.waitForTimeout(500); diff --git a/e2e/tests/export-import.spec.ts b/e2e/tests/export-import.spec.ts index e0f1ac5..7fea4cf 100644 --- a/e2e/tests/export-import.spec.ts +++ b/e2e/tests/export-import.spec.ts @@ -1,12 +1,10 @@ import { test, expect } from "@playwright/test"; -import * as fs from "fs"; -import * as path from "path"; import { API_URL, createDrawing, deleteDrawing, + getCsrfHeaders, listDrawings, - createCollection, deleteCollection, } from "./helpers/api"; @@ -29,7 +27,7 @@ test.describe("Export Functionality", () => { for (const id of createdDrawingIds) { try { await deleteDrawing(request, id); - } catch (e) { + } catch { // Ignore cleanup errors } } @@ -38,7 +36,7 @@ test.describe("Export Functionality", () => { for (const id of createdCollectionIds) { try { await deleteCollection(request, id); - } catch (e) { + } catch { // Ignore cleanup errors } } @@ -85,11 +83,11 @@ test.describe("Export Functionality", () => { // Test JSON/ZIP export endpoint - it returns a ZIP file with .excalidraw files const zipResponse = await request.get(`${API_URL}/export/json`); expect(zipResponse.ok()).toBe(true); - + // Check it's a ZIP file const contentType = zipResponse.headers()["content-type"]; expect(contentType).toMatch(/application\/zip/); - + // Check content-disposition header const contentDisposition = zipResponse.headers()["content-disposition"]; expect(contentDisposition).toContain("attachment"); @@ -103,11 +101,11 @@ test.describe("Export Functionality", () => { // Test SQLite export endpoint const sqliteResponse = await request.get(`${API_URL}/export`); expect(sqliteResponse.ok()).toBe(true); - + // Check content-type header indicates a file download const contentType = sqliteResponse.headers()["content-type"]; expect(contentType).toMatch(/application\/octet-stream|application\/x-sqlite3/); - + // Check content-disposition header const contentDisposition = sqliteResponse.headers()["content-disposition"]; expect(contentDisposition).toContain("attachment"); @@ -121,7 +119,7 @@ test.describe("Export Functionality", () => { // Test .db export endpoint const dbResponse = await request.get(`${API_URL}/export?format=db`); expect(dbResponse.ok()).toBe(true); - + const contentDisposition = dbResponse.headers()["content-disposition"]; expect(contentDisposition).toContain("attachment"); expect(contentDisposition).toMatch(/\.db/); @@ -137,7 +135,7 @@ test.describe.serial("Import Functionality", () => { for (const drawing of testDrawings) { try { await deleteDrawing(request, drawing.id); - } catch (e) { + } catch { // Ignore cleanup errors } } @@ -145,7 +143,7 @@ test.describe.serial("Import Functionality", () => { for (const id of createdDrawingIds) { try { await deleteDrawing(request, id); - } catch (e) { + } catch { // Ignore cleanup errors } } @@ -161,7 +159,7 @@ test.describe.serial("Import Functionality", () => { await expect(importButton).toBeVisible(); }); - test("should import .excalidraw file from Dashboard", async ({ page, request }) => { + test("should import .excalidraw file from Dashboard", async ({ page }) => { await page.goto("/"); await page.waitForLoadState("networkidle"); @@ -206,15 +204,14 @@ test.describe.serial("Import Functionality", () => { }); // Write temp file - const tempDir = "/tmp"; - const tempFile = `${tempDir}/Import_Test_${Date.now()}.excalidraw`; - + // tempFile was here + // Use page.evaluate to check if we can proceed // Actually, Playwright has setInputFiles which can handle this // Find the import file input const fileInput = page.locator("#dashboard-import"); - + // Create a buffer from the fixture content await fileInput.setInputFiles({ name: `Import_ExcalidrawTest_${Date.now()}.excalidraw`, @@ -237,13 +234,13 @@ test.describe.serial("Import Functionality", () => { await expect(importedCards.first()).toBeVisible({ timeout: 10000 }); }); - test("should import JSON drawing file from Dashboard", async ({ page, request }) => { + test("should import JSON drawing file from Dashboard", async ({ page }) => { await page.goto("/"); await page.waitForLoadState("networkidle"); const timestamp = Date.now(); const testName = `Import_JSONTest_${timestamp}`; - + // Create a valid excalidraw JSON file with required fields const jsonContent = JSON.stringify({ type: "excalidraw", @@ -283,7 +280,7 @@ test.describe.serial("Import Functionality", () => { }); const fileInput = page.locator("#dashboard-import"); - + await fileInput.setInputFiles({ name: `${testName}.json`, mimeType: "application/json", @@ -293,9 +290,9 @@ test.describe.serial("Import Functionality", () => { // Wait for import result - could be success or failure const successModal = page.getByText("Import Successful"); const failModal = page.getByText("Import Failed"); - + await expect(successModal.or(failModal)).toBeVisible({ timeout: 15000 }); - + // If we got a failure, check the error if (await failModal.isVisible()) { // Get the error message @@ -306,7 +303,7 @@ test.describe.serial("Import Functionality", () => { // Skip the rest of the test since import failed return; } - + await page.getByRole("button", { name: "OK" }).click(); // Reload to force a fresh fetch of drawings after import @@ -331,7 +328,7 @@ test.describe.serial("Import Functionality", () => { const invalidContent = "this is not valid JSON or excalidraw format {}{}"; const fileInput = page.locator("#dashboard-import"); - + await fileInput.setInputFiles({ name: `Import_Invalid_${Date.now()}.excalidraw`, mimeType: "application/json", @@ -394,6 +391,7 @@ test.describe("Database Import Verification", () => { // Test that the verification endpoint responds // We don't actually import a database as that would affect the test environment const response = await request.post(`${API_URL}/import/sqlite/verify`, { + headers: await getCsrfHeaders(request), // Send empty form data to test endpoint exists multipart: { db: { @@ -403,7 +401,7 @@ test.describe("Database Import Verification", () => { }, }, }); - + // Should get an error response since the file is empty/invalid // But the endpoint should exist expect([400, 500]).toContain(response.status()); diff --git a/e2e/tests/helpers/api.ts b/e2e/tests/helpers/api.ts index cd3843c..6805e63 100644 --- a/e2e/tests/helpers/api.ts +++ b/e2e/tests/helpers/api.ts @@ -5,6 +5,91 @@ const DEFAULT_BACKEND_PORT = 8000; export const API_URL = process.env.API_URL || `http://localhost:${DEFAULT_BACKEND_PORT}`; +type CsrfTokenResponse = { + token: string; + header?: string; +}; + +type CsrfInfo = { + token: string; + headerName: string; +}; + +// Cache CSRF tokens per Playwright request context so parallel tests don't race. +const csrfInfoByRequest = new WeakMap(); +const csrfFetchByRequest = new WeakMap>(); + +const fetchCsrfInfo = async (request: APIRequestContext): Promise => { + const response = await request.get(`${API_URL}/csrf-token`); + if (!response.ok()) { + const text = await response.text(); + throw new Error( + `Failed to fetch CSRF token: ${response.status()} ${text || "(empty response)"}` + ); + } + + const data = (await response.json()) as CsrfTokenResponse; + if (!data || typeof data.token !== "string" || data.token.trim().length === 0) { + throw new Error("Failed to fetch CSRF token: missing token in response"); + } + + const headerName = + typeof data.header === "string" && data.header.trim().length > 0 + ? data.header + : "x-csrf-token"; + + return { token: data.token, headerName }; +}; + +const getCsrfInfo = async (request: APIRequestContext): Promise => { + const cached = csrfInfoByRequest.get(request); + if (cached) return cached; + + const inFlight = csrfFetchByRequest.get(request); + if (inFlight) return inFlight; + + const promise = fetchCsrfInfo(request) + .then((info) => { + csrfInfoByRequest.set(request, info); + return info; + }) + .finally(() => { + csrfFetchByRequest.delete(request); + }); + + csrfFetchByRequest.set(request, promise); + return promise; +}; + +const refreshCsrfInfo = async (request: APIRequestContext): Promise => { + const promise = fetchCsrfInfo(request) + .then((info) => { + csrfInfoByRequest.set(request, info); + return info; + }) + .finally(() => { + csrfFetchByRequest.delete(request); + }); + + csrfFetchByRequest.set(request, promise); + return promise; +}; + +export async function getCsrfHeaders( + request: APIRequestContext +): Promise> { + const info = await getCsrfInfo(request); + return { [info.headerName]: info.token }; +} + +const withCsrfHeaders = async ( + request: APIRequestContext, + headers: Record = {} +): Promise> => ({ + ...headers, + ...(await getCsrfHeaders(request)), +}); + export interface DrawingRecord { id: string; name: string; @@ -53,10 +138,26 @@ export async function createDrawing( overrides: CreateDrawingOptions = {} ): Promise { const payload = { ...defaultDrawingPayload(), ...overrides }; - const response = await request.post(`${API_URL}/drawings`, { - headers: { "Content-Type": "application/json" }, + const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" }); + + let response = await request.post(`${API_URL}/drawings`, { + headers, data: payload, }); + + // Retry once with a fresh token in case it expired or the cache was primed under + // a different clientId (rare, but can happen under parallelism / CI proxies). + if (!response.ok() && response.status() === 403) { + await refreshCsrfInfo(request); + const retryHeaders = await withCsrfHeaders(request, { + "Content-Type": "application/json", + }); + response = await request.post(`${API_URL}/drawings`, { + headers: retryHeaders, + data: payload, + }); + } + if (!response.ok()) { const text = await response.text(); throw new Error(`Failed to create drawing: ${response.status()} ${text}`); @@ -77,7 +178,17 @@ export async function deleteDrawing( request: APIRequestContext, id: string ): Promise { - const response = await request.delete(`${API_URL}/drawings/${id}`); + const headers = await withCsrfHeaders(request); + let response = await request.delete(`${API_URL}/drawings/${id}`, { headers }); + + if (!response.ok() && response.status() === 403) { + await refreshCsrfInfo(request); + const retryHeaders = await withCsrfHeaders(request); + response = await request.delete(`${API_URL}/drawings/${id}`, { + headers: retryHeaders, + }); + } + if (!response.ok()) { // Ignore not found to keep cleanup idempotent if (response.status() !== 404) { @@ -113,10 +224,24 @@ export async function createCollection( request: APIRequestContext, name: string ): Promise { - const response = await request.post(`${API_URL}/collections`, { - headers: { "Content-Type": "application/json" }, + const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" }); + + let response = await request.post(`${API_URL}/collections`, { + headers, data: { name }, }); + + if (!response.ok() && response.status() === 403) { + await refreshCsrfInfo(request); + const retryHeaders = await withCsrfHeaders(request, { + "Content-Type": "application/json", + }); + response = await request.post(`${API_URL}/collections`, { + headers: retryHeaders, + data: { name }, + }); + } + expect(response.ok()).toBe(true); return (await response.json()) as CollectionRecord; } @@ -133,7 +258,17 @@ export async function deleteCollection( request: APIRequestContext, id: string ): Promise { - const response = await request.delete(`${API_URL}/collections/${id}`); + const headers = await withCsrfHeaders(request); + let response = await request.delete(`${API_URL}/collections/${id}`, { headers }); + + if (!response.ok() && response.status() === 403) { + await refreshCsrfInfo(request); + const retryHeaders = await withCsrfHeaders(request); + response = await request.delete(`${API_URL}/collections/${id}`, { + headers: retryHeaders, + }); + } + if (!response.ok()) { if (response.status() !== 404) { const text = await response.text(); diff --git a/e2e/tests/image-persistence.spec.ts b/e2e/tests/image-persistence.spec.ts index c163cf5..5e78ff5 100644 --- a/e2e/tests/image-persistence.spec.ts +++ b/e2e/tests/image-persistence.spec.ts @@ -1,7 +1,13 @@ import { test, expect } from "@playwright/test"; import * as fs from "fs"; import * as path from "path"; -import { API_URL, createDrawing, deleteDrawing, getDrawing } from "./helpers/api"; +import { + API_URL, + createDrawing, + deleteDrawing, + getCsrfHeaders, + getDrawing, +} from "./helpers/api"; /** * E2E Browser Tests for Image Persistence - Issue #17 Regression @@ -28,13 +34,13 @@ function generateLargeImageDataUrl(sizeInBytes: number = 50000): string { test.describe("Image Persistence - Browser E2E Tests", () => { let testDrawingIds: string[] = []; - + test.afterEach(async ({ request }) => { // Clean up any drawings created during tests for (const id of testDrawingIds) { try { await deleteDrawing(request, id); - } catch (e) { + } catch { // Ignore cleanup errors } } @@ -43,23 +49,23 @@ test.describe("Image Persistence - Browser E2E Tests", () => { test("should navigate to dashboard and see drawing list", async ({ page }) => { await page.goto("/"); - + // Wait for the page to load await expect(page).toHaveTitle(/ExcaliDash/i); - + // The dashboard should show some UI elements await expect(page.locator("body")).toBeVisible(); }); test("should create a new drawing via UI", async ({ page }) => { await page.goto("/"); - + // Look for a "New Drawing" or similar button const newDrawingBtn = page.getByRole("button", { name: /new|create/i }).first(); - + if (await newDrawingBtn.isVisible()) { await newDrawingBtn.click(); - + // Should navigate to editor or show a modal await page.waitForURL(/\/(editor|drawing)/i, { timeout: 5000 }).catch(() => { // May stay on same page with modal @@ -71,7 +77,7 @@ test.describe("Image Persistence - Browser E2E Tests", () => { // This is the core regression test for issue #17 const largeDataUrl = generateLargeImageDataUrl(50000); expect(largeDataUrl.length).toBeGreaterThan(10000); - + const files = { "test-image-1": { id: "test-image-1", @@ -80,23 +86,23 @@ test.describe("Image Persistence - Browser E2E Tests", () => { created: Date.now(), }, }; - + // Create drawing with large image const createdDrawing = await createDrawing(request, { name: "E2E Test - Large Image", files, }); testDrawingIds.push(createdDrawing.id); - + // Retrieve the drawing const drawing = await getDrawing(request, createdDrawing.id); const savedFiles = drawing.files || {}; // Already parsed by API - + // Verify the image data was preserved expect(savedFiles["test-image-1"]).toBeDefined(); expect(savedFiles["test-image-1"].dataURL).toBe(largeDataUrl); expect(savedFiles["test-image-1"].dataURL.length).toBe(largeDataUrl.length); - + console.log("✓ Large image data preserved correctly through save/reload cycle"); }); @@ -106,36 +112,36 @@ test.describe("Image Persistence - Browser E2E Tests", () => { name: "E2E Test - Editor View", }); testDrawingIds.push(createdDrawing.id); - + // Navigate to the editor await page.goto(`/editor/${createdDrawing.id}`); - + // Wait for the page to load await page.waitForLoadState("networkidle"); - + // The editor should be visible (Excalidraw canvas) // Look for the Excalidraw container or canvas const editorContainer = page.locator("[class*='excalidraw'], canvas").first(); await expect(editorContainer).toBeVisible({ timeout: 10000 }); }); - test("should import .excalidraw file with embedded image", async ({ page, request }) => { + test("should import .excalidraw file with embedded image", async ({ request }) => { // Load the test fixture const fixturePath = path.join(__dirname, "..", "fixtures", "small-image.excalidraw"); const fixtureContent = fs.readFileSync(fixturePath, "utf-8"); const fixtureData = JSON.parse(fixtureContent); - - // Create drawing via API with fixture data - const createdDrawing = await createDrawing(request, { - name: "E2E Test - Imported Image", - files: fixtureData.files, - }); - testDrawingIds.push(createdDrawing.id); - + + // Create drawing via API with fixture data + const createdDrawing = await createDrawing(request, { + name: "E2E Test - Imported Image", + files: fixtureData.files, + }); + testDrawingIds.push(createdDrawing.id); + // Verify via API that image data was preserved - const drawing = await getDrawing(request, createdDrawing.id); + const drawing = await getDrawing(request, createdDrawing.id); const savedFiles = drawing.files || {}; // Already parsed by API - + expect(savedFiles["embedded-test-image"]).toBeDefined(); expect(savedFiles["embedded-test-image"].dataURL).toBe(fixtureData.files["embedded-test-image"].dataURL); }); @@ -161,23 +167,23 @@ test.describe("Image Persistence - Browser E2E Tests", () => { created: Date.now(), }, }; - + const createdDrawing = await createDrawing(request, { name: "E2E Test - Multiple Images", files, }); testDrawingIds.push(createdDrawing.id); - + const drawing = await getDrawing(request, createdDrawing.id); const savedFiles = drawing.files || {}; // Already parsed by API - + // Verify all images preserved correctly for (const [id, originalFile] of Object.entries(files)) { expect(savedFiles[id]).toBeDefined(); expect(savedFiles[id].dataURL).toBe((originalFile as any).dataURL); expect(savedFiles[id].dataURL.length).toBe((originalFile as any).dataURL.length); } - + console.log("✓ Multiple images of varying sizes preserved correctly"); }); }); @@ -192,10 +198,11 @@ test.describe("Security - Malicious Content Blocking", () => { created: Date.now(), }, }; - + const response = await request.post(`${API_URL}/drawings`, { headers: { "Content-Type": "application/json", + ...(await getCsrfHeaders(request)), }, data: { name: "Security Test - JS URL", @@ -205,7 +212,7 @@ test.describe("Security - Malicious Content Blocking", () => { preview: null, }, }); - + if (!response.ok()) { const text = await response.text(); console.error(`API Error: ${response.status()} - ${text}`); @@ -213,12 +220,14 @@ test.describe("Security - Malicious Content Blocking", () => { expect(response.ok()).toBe(true); const drawing = await response.json(); const savedFiles = drawing.files; // Already parsed by API - + // The malicious URL should be blocked/cleared expect(savedFiles["malicious-image"].dataURL).not.toContain("javascript:"); - + // Cleanup - await request.delete(`${API_URL}/drawings/${drawing.id}`); + await request.delete(`${API_URL}/drawings/${drawing.id}`, { + headers: await getCsrfHeaders(request), + }); }); test("should block script tags in image data", async ({ request }) => { @@ -230,10 +239,11 @@ test.describe("Security - Malicious Content Blocking", () => { created: Date.now(), }, }; - + const response = await request.post(`${API_URL}/drawings`, { headers: { "Content-Type": "application/json", + ...(await getCsrfHeaders(request)), }, data: { name: "Security Test - Script Tag", @@ -243,7 +253,7 @@ test.describe("Security - Malicious Content Blocking", () => { preview: null, }, }); - + if (!response.ok()) { const text = await response.text(); console.error(`API Error: ${response.status()} - ${text}`); @@ -251,11 +261,13 @@ test.describe("Security - Malicious Content Blocking", () => { expect(response.ok()).toBe(true); const drawing = await response.json(); const savedFiles = drawing.files; // Already parsed by API - + // The script tag should be blocked expect(savedFiles["malicious-image"].dataURL).not.toContain("