diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..2a9f153 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,1310 @@ +{ + "name": "shuffle-e2e", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shuffle-e2e", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "^1.55.0", + "@types/node": "^22.0.0", + "@types/pg": "^8.11.0", + "pg": "^8.13.0", + "serve": "^14.2.4", + "typescript": "^5.6.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serve": { + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.18.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.7", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..773e036 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,21 @@ +{ + "name": "shuffle-e2e", + "private": true, + "version": "0.0.0", + "description": "Playwright E2E tests for Shuffle (auth: KTO-01, Issue #38)", + "scripts": { + "test": "playwright test", + "test:api": "playwright test --project=api", + "test:web": "playwright test --project=web", + "test:throttle": "playwright test --project=throttle", + "test:spike": "playwright test --project=spike" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@types/node": "^22.0.0", + "@types/pg": "^8.11.0", + "pg": "^8.13.0", + "serve": "^14.2.4", + "typescript": "^5.6.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..0591fdc --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,52 @@ +import { defineConfig, devices, type Project } from '@playwright/test'; + +const webUrl = process.env.WEB_URL ?? 'http://localhost:3000'; + +/** + * Projekte (Spec #1 § 7): + * - api GoTrue ueber Kong + Mailpit-Helfer + * - web gebaute Flutter-Web-App (`app/build/web`, statisch auf Port 3000) + * - throttle Drosselung (Krit. 7) gegen den Extra-Container, nur mit THROTTLE_GOTRUE_URL + * - spike SMTP-Spike (Issue #37), nur mit SPIKE_GOTRUE_URL, nicht blockierend + */ +const projects: Project[] = [ + { name: 'api', testDir: './tests/api', use: {} }, + { + name: 'web', + testDir: './tests/web', + use: { + ...devices['Desktop Chrome'], + baseURL: webUrl, + // Englische Texte (app_en.arb); die App waehlt die Sprache nach dem Browser. + locale: 'en-US', + // Der Flutter-Service-Worker wuerde alte Builds cachen. + serviceWorkers: 'block', + }, + }, +]; + +if (process.env.THROTTLE_GOTRUE_URL) { + projects.push({ name: 'throttle', testDir: './tests/throttle', use: {} }); +} +if (process.env.SPIKE_GOTRUE_URL) { + projects.push({ name: 'spike', testDir: './tests/spike', use: {}, retries: 0 }); +} + +export default defineConfig({ + timeout: 60_000, + expect: { timeout: 10_000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : 4, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']], + use: { trace: 'retain-on-failure' }, + projects, + // SPA-Fallback (`-s`), sonst liefert /auth/confirm?... 404. + webServer: { + command: 'npx serve -s ../app/build/web -l 3000 --no-clipboard', + url: webUrl, + reuseExistingServer: true, + timeout: 30_000, + }, +}); diff --git a/e2e/support/auth-config.ts b/e2e/support/auth-config.ts new file mode 100644 index 0000000..c9c620e --- /dev/null +++ b/e2e/support/auth-config.ts @@ -0,0 +1,82 @@ +/** + * Konstanten der Auth-Konfiguration (Spec #1 § 4.1, `docs/auth-konfiguration.md`). + * + * Diese Datei muss mit `supabase/config.toml` und `docs/auth-konfiguration.md` uebereinstimmen; + * `tests/api/config.spec.ts` prueft das. Tests lesen Zahlen nur von hier und verweisen im Namen + * auf die Zeilen-ID (z. B. `AUTH-CFG-07`). + * + * Werte, die lokal (Supabase CLI) und in Staging/Prod unterschiedlich sind, tragen hier den + * LOKALEN Wert (die E2E-Tests laufen nur lokal bzw. in der CI gegen die CLI). + */ + +/** Umgebung (Standardwerte = lokale Demo-Werte der Supabase CLI, keine Secrets). */ +export const env = { + /** Kong, wie die App. */ + supabaseUrl: process.env.SUPABASE_URL ?? 'http://127.0.0.1:54321', + /** Legacy-JWT-Anon-Key der CLI (oeffentlicher Demo-Wert). */ + anonKey: + process.env.SUPABASE_ANON_KEY ?? + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0', + /** Legacy-Service-Role-Key der CLI (oeffentlicher Demo-Wert, nur lokal). */ + serviceRoleKey: + process.env.SUPABASE_SERVICE_ROLE_KEY ?? + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU', + mailpitUrl: process.env.MAILPIT_URL ?? 'http://127.0.0.1:54324', + dbUrl: process.env.E2E_DB_URL ?? 'postgresql://postgres:postgres@127.0.0.1:54322/postgres', + /** Web-Build. Immer `localhost` (nicht 127.0.0.1): Die Mail-Links nutzen AUTH-CFG-01. */ + webUrl: process.env.WEB_URL ?? 'http://localhost:3000', + /** Zusaetzlicher GoTrue-Container mit AUTH-CFG-13 (Projekt `throttle`, Skript start-throttle-gotrue.sh). */ + throttleGotrueUrl: process.env.THROTTLE_GOTRUE_URL, + /** GoTrue-Container mit unerreichbarem SMTP (Projekt `spike`, Issue #37). */ + spikeGotrueUrl: process.env.SPIKE_GOTRUE_URL, +} as const; + +export const authConfig = { + /** AUTH-CFG-01: Site-URL lokal. */ + siteUrl: 'http://localhost:3000', + /** AUTH-CFG-05: Passwort-Mindestlaenge. */ + passwordMinLength: 10, + /** AUTH-CFG-07: Gueltigkeit Bestaetigungs- und Reset-Link in Sekunden. */ + otpExpirySeconds: 3600, + /** AUTH-CFG-08: Mindestabstand zwischen Mails je Nutzer, lokal 1 s. */ + maxFrequencySeconds: 1, + /** AUTH-CFG-10: Burst des Login-Limiters je IP (Login #31 -> 429). */ + loginBurst: 30, + /** AUTH-CFG-11: Burst des OTP-Limiters (Signup, Recover, Resend) je IP. */ + otpBurst: 30, + /** AUTH-CFG-14: JWT-Laufzeit in Sekunden. */ + jwtExpirySeconds: 3600, + /** AUTH-CFG-15: Wiederverwendungsfenster der Refresh-Token-Rotation in Sekunden. */ + refreshTokenReuseIntervalSeconds: 10, + /** Anzeigename: Grenzen in Codepoints (Spec 3.2). */ + displayNameMaxLength: 30, +} as const; + +/** Mailbetreffe (AUTH-CFG-17/-18, `supabase/config.toml`). */ +export const subjects = { + confirmation: { de: 'Bestätige deine E-Mail-Adresse für Shuffle', en: 'Confirm your email address for Shuffle' }, + recovery: { de: 'Passwort für Shuffle zurücksetzen', en: 'Reset your Shuffle password' }, +} as const; + +/** Fehlercodes von GoTrue, auf die sich die Spec stuetzt (§ 4.3/4.4). */ +export const errorCodes = { + invalidCredentials: 'invalid_credentials', + emailNotConfirmed: 'email_not_confirmed', + weakPassword: 'weak_password', + samePassword: 'same_password', + otpExpired: 'otp_expired', + overRequestRateLimit: 'over_request_rate_limit', + overEmailSendRateLimit: 'over_email_send_rate_limit', + userAlreadyExists: 'user_already_exists', +} as const; + +/** Trigger-Fehlertexte bei signUp (HTTP 500, Body `{"code":"P0001","message":...}`, B0 (f)). */ +export const triggerErrors = { + displayNameInvalid: 'display_name_invalid', + ageConfirmationRequired: 'age_confirmation_required', + termsAcceptanceRequired: 'terms_acceptance_required', + termsVersionRequired: 'terms_version_required', +} as const; + +/** Version der Rechtstexte, die der Web-Build sendet (`app/env/ci.example.json`). */ +export const TERMS_VERSION = process.env.TERMS_VERSION ?? '2026-11-draft'; diff --git a/e2e/support/db.ts b/e2e/support/db.ts new file mode 100644 index 0000000..60c5118 --- /dev/null +++ b/e2e/support/db.ts @@ -0,0 +1,78 @@ +/** + * Direkter Datenbankzugriff fuer Tests. NUR lokal/CI: der Helfer bricht ab, wenn der Host nicht + * lokal ist (er veraendert `auth.users`). Spec 7.1 Krit. 8 (B0 (i)): Maßgeblich fuer den + * Linkablauf sind `auth.users.recovery_sent_at` und `confirmation_sent_at`, nicht + * `auth.one_time_tokens.created_at`. + */ +import { Client } from 'pg'; +import { authConfig, env } from './auth-config'; + +function assertLocal(url: string): void { + const host = new URL(url).hostname; + if (!['127.0.0.1', 'localhost', '::1', 'host.docker.internal'].includes(host)) { + throw new Error(`e2e/support/db.ts nur lokal/CI: Host "${host}" ist nicht erlaubt`); + } +} + +export async function withDb(fn: (c: Client) => Promise): Promise { + assertLocal(env.dbUrl); + const client = new Client({ connectionString: env.dbUrl }); + await client.connect(); + try { + return await fn(client); + } finally { + await client.end(); + } +} + +export async function countUsers(): Promise { + return withDb(async (c) => Number((await c.query('select count(*) from auth.users')).rows[0].count)); +} + +export async function userByEmail(email: string) { + return withDb(async (c) => (await c.query('select * from auth.users where email = $1', [email.toLowerCase()])).rows[0]); +} + +export async function profileByEmail(email: string) { + return withDb( + async (c) => + ( + await c.query( + `select p.* from public.profiles p join auth.users u on u.id = p.id where u.email = $1`, + [email.toLowerCase()], + ) + ).rows[0], + ); +} + +/** Setzt den Link als abgelaufen: `recovery_sent_at = now() - (otp_expiry + 1 s)` (AUTH-CFG-07). */ +export async function expireRecoveryLink(email: string, secondsAgo = authConfig.otpExpirySeconds + 1) { + await withDb((c) => + c.query(`update auth.users set recovery_sent_at = now() - make_interval(secs => $2) where email = $1`, [ + email.toLowerCase(), + secondsAgo, + ]), + ); +} + +/** Wie oben fuer den Bestaetigungslink (`confirmation_sent_at`). */ +export async function expireConfirmationLink(email: string, secondsAgo = authConfig.otpExpirySeconds + 1) { + await withDb((c) => + c.query(`update auth.users set confirmation_sent_at = now() - make_interval(secs => $2) where email = $1`, [ + email.toLowerCase(), + secondsAgo, + ]), + ); +} + +/** `not_after` aller Sitzungen des Nutzers (AUTH-CFG-16: nicht gesetzt). */ +export async function sessionNotAfter(email: string): Promise<(string | null)[]> { + return withDb(async (c) => + ( + await c.query( + `select s.not_after from auth.sessions s join auth.users u on u.id = s.user_id where u.email = $1`, + [email.toLowerCase()], + ) + ).rows.map((r) => r.not_after), + ); +} diff --git a/e2e/support/gotrue.ts b/e2e/support/gotrue.ts new file mode 100644 index 0000000..b255d99 --- /dev/null +++ b/e2e/support/gotrue.ts @@ -0,0 +1,134 @@ +import { randomBytes, randomUUID } from 'node:crypto'; +import { authConfig, env, TERMS_VERSION } from './auth-config'; + +/** Zufaellige E-Mail-Adresse je Test (Spec 7: jeder Test nutzt eigene Adressen). */ +export function randomEmail(prefix = 'e2e'): string { + return `${prefix}-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}@example.test`; +} + +/** Zufaelliges, ausreichend langes Passwort (AUTH-CFG-05: mind. 10 Zeichen). */ +export function randomPassword(): string { + return `Pw-${randomBytes(8).toString('hex')}-x`; +} + +/** Zufaellige IPv4 fuer `X-Forwarded-For` (nur Projekt `throttle`, nie gegen die CLI, R13). */ +export function randomIp(): string { + const octet = () => 1 + Math.floor(Math.random() * 253); + return `${octet()}.${octet()}.${octet()}.${octet()}`; +} + +export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Wartet etwas mehr als AUTH-CFG-08 (1 s), damit Folgemails nicht an 429 scheitern. */ +export const waitMailInterval = () => sleep(authConfig.maxFrequencySeconds * 1000 + 300); + +export interface GoTrueResponse { + status: number; + body: T; + headers: Headers; + /** Dauer in Millisekunden. */ + ms: number; +} + +export interface ClientOptions { + /** Basis-URL inkl. `/auth/v1` (Standard: Kong). */ + baseUrl?: string; + /** Beim Zugriff ueber Kong noetig, direkt auf GoTrue nicht. */ + apikey?: string | null; + /** Zusatz-Header, z. B. `X-Forwarded-For` (nur `throttle`). */ + headers?: Record; +} + +/** Minimaler GoTrue-Client (REST, wie `supabase_flutter`). */ +export class GoTrueClient { + readonly baseUrl: string; + private readonly apikey: string | null; + private readonly extraHeaders: Record; + + constructor(opts: ClientOptions = {}) { + this.baseUrl = opts.baseUrl ?? `${env.supabaseUrl}/auth/v1`; + this.apikey = opts.apikey === undefined ? env.anonKey : opts.apikey; + this.extraHeaders = opts.headers ?? {}; + } + + async request( + method: string, + path: string, + body?: unknown, + token?: string, + extra: Record = {}, + ): Promise> { + const headers: Record = { 'content-type': 'application/json', ...this.extraHeaders, ...extra }; + if (this.apikey) headers.apikey = this.apikey; + if (token) headers.authorization = `Bearer ${token}`; + const started = Date.now(); + const res = await fetch(`${this.baseUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + let parsed: any = text; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + /* Body ist kein JSON */ + } + return { status: res.status, body: parsed, headers: res.headers, ms: Date.now() - started }; + } + + signUp(email: string, password: string, data: Record | null = validMetadata()) { + return this.request('POST', '/signup', { email, password, ...(data ? { data } : {}) }); + } + signIn(email: string, password: string) { + return this.request('POST', '/token?grant_type=password', { email, password }); + } + refresh(refreshToken: string) { + return this.request('POST', '/token?grant_type=refresh_token', { refresh_token: refreshToken }); + } + recover(email: string) { + return this.request('POST', '/recover', { email }); + } + resend(email: string, type: 'signup' = 'signup') { + return this.request('POST', '/resend', { email, type }); + } + /** `verifyOTP(tokenHash, type)`. */ + verify(tokenHash: string, type: 'email' | 'signup' | 'recovery') { + return this.request('POST', '/verify', { token_hash: tokenHash, type }); + } + getUser(accessToken: string) { + return this.request('GET', '/user', undefined, accessToken); + } + updateUser(accessToken: string, attributes: Record) { + return this.request('PUT', '/user', attributes, accessToken); + } + /** Legt ein bestaetigtes Konto direkt an (Service-Role, nur lokal), fuer Tests, die die Anmeldung pruefen. */ + async createConfirmedUser( + email: string, + password: string, + data: Record = validMetadata(), + ) { + return this.request( + 'POST', + '/admin/users', + { email, password, email_confirm: true, user_metadata: data }, + env.serviceRoleKey, + ); + } +} + +/** Gueltige Registrierungs-Metadaten (Spec 4.2). */ +export function validMetadata(overrides: Record = {}): Record { + const data: Record = { + display_name: 'E2E Tester', + age_confirmed: true, + terms_accepted: true, + terms_version: TERMS_VERSION, + locale: 'en', + ...overrides, + }; + for (const key of Object.keys(data)) if (data[key] === undefined) delete data[key]; + return data; +} + +export const uniqueId = () => randomUUID(); diff --git a/e2e/support/mailpit.ts b/e2e/support/mailpit.ts new file mode 100644 index 0000000..805b822 --- /dev/null +++ b/e2e/support/mailpit.ts @@ -0,0 +1,80 @@ +import { env } from './auth-config'; +import { sleep } from './gotrue'; + +export interface MailSummary { + ID: string; + Subject: string; + To: { Address: string }[]; + Created: string; +} + +export interface Mail extends MailSummary { + html: string; + text: string; +} + +async function getJson(path: string): Promise { + const res = await fetch(`${env.mailpitUrl}/api/v1${path}`); + if (!res.ok) throw new Error(`Mailpit ${path}: HTTP ${res.status}`); + return res.json(); +} + +/** Alle Mails an eine Adresse (Suche per `to:`; nie global loeschen, Tests laufen parallel). */ +export async function mailsTo(address: string): Promise { + const result = await getJson(`/search?query=${encodeURIComponent(`to:"${address}"`)}&limit=50`); + return (result.messages ?? []) as MailSummary[]; +} + +export async function countMails(address: string): Promise { + return (await mailsTo(address)).length; +} + +export async function readMail(id: string): Promise { + const m = await getJson(`/message/${id}`); + return { ID: m.ID, Subject: m.Subject, To: m.To, Created: m.Date ?? '', html: m.HTML ?? '', text: m.Text ?? '' }; +} + +/** + * Wartet, bis mindestens `count` Mails an die Adresse vorliegen, und liefert die neueste. + * Faellt nach `timeoutMs` mit einer aussagekraeftigen Meldung durch. + */ +export async function waitForMail(address: string, count = 1, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + let found: MailSummary[] = []; + while (Date.now() < deadline) { + found = await mailsTo(address); + if (found.length >= count) { + // Mailpit liefert die neueste zuerst. + return readMail(found[0].ID); + } + await sleep(200); + } + throw new Error(`Keine ${count}. Mail an ${address} nach ${timeoutMs} ms (vorhanden: ${found.length})`); +} + +/** + * Belegt „es kommt keine (weitere) Mail": wartet ein festes Fenster und zaehlt dann. + * Nur dort einsetzen, wo die Abwesenheit selbst das Kriterium ist (Krit. 4, 8, 11). + */ +export async function expectMailCount(address: string, expected: number, windowMs = 2_000): Promise { + await sleep(windowMs); + const actual = await countMails(address); + if (actual !== expected) throw new Error(`Mails an ${address}: erwartet ${expected}, vorhanden ${actual}`); + return actual; +} + +/** Link aus der Mail: `.../auth/confirm?token_hash=...&type=email|recovery` (HTML-Entities decodiert). */ +export function extractConfirmLink(mail: Mail): URL { + const decoded = mail.html.replace(/&/g, '&'); + const match = decoded.match(/https?:\/\/[^"'\s<]+\/auth\/confirm\?[^"'\s<]+/); + if (!match) throw new Error(`Kein Bestaetigungslink in der Mail "${mail.Subject}"`); + return new URL(match[0]); +} + +export function extractTokenHash(mail: Mail): { tokenHash: string; type: string; link: URL } { + const link = extractConfirmLink(mail); + const tokenHash = link.searchParams.get('token_hash'); + const type = link.searchParams.get('type'); + if (!tokenHash || !type) throw new Error('token_hash oder type fehlen im Link'); + return { tokenHash, type, link }; +} diff --git a/e2e/support/start-throttle-gotrue.sh b/e2e/support/start-throttle-gotrue.sh new file mode 100755 index 0000000..4cb4d88 --- /dev/null +++ b/e2e/support/start-throttle-gotrue.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Startet einen ZWEITEN GoTrue-Container neben der Supabase CLI (Spec #1 D2/T3, F11). +# +# Profile +# throttle (Standard) AUTH-CFG-13: GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For, Port 9998. +# Fuer das Playwright-Projekt `throttle` (THROTTLE_GOTRUE_URL=http://127.0.0.1:9998). +# smtp-down SMTP-Spike (Issue #37): wie die CLI, aber SMTP-Port 1 (Verbindung abgelehnt), Port 9997. +# Fuer das Playwright-Projekt `spike` (SPIKE_GOTRUE_URL=http://127.0.0.1:9997). +# +# Aufruf +# e2e/support/start-throttle-gotrue.sh start [throttle|smtp-down] +# e2e/support/start-throttle-gotrue.sh stop [throttle|smtp-down] +# +# Der Container nutzt dasselbe Image, dieselbe DB und dasselbe Docker-Netz wie der Auth-Container +# der CLI; die Umgebung wird per `docker inspect` uebernommen (Version nicht hart kodiert). Die +# Rate-Limit-Buckets liegen im Speicher und sind damit von der CLI getrennt (R13). +# Nur lokal/CI benutzen: Die kopierte Umgebung enthaelt die lokalen Demo-Secrets der CLI. +set -euo pipefail + +ACTION="${1:-start}" +PROFILE="${2:-throttle}" +SOURCE="${SOURCE_CONTAINER:-$(docker ps --format '{{.Names}}' | grep -E '^supabase_auth_' | head -n1 || true)}" + +case "$PROFILE" in + throttle) NAME="${CONTAINER_NAME:-shuffle_auth_throttle}"; HOST_PORT="${HOST_PORT:-9998}" ;; + smtp-down) NAME="${CONTAINER_NAME:-shuffle_auth_smtp_down}"; HOST_PORT="${HOST_PORT:-9997}" ;; + *) echo "Unbekanntes Profil: $PROFILE (throttle|smtp-down)" >&2; exit 2 ;; +esac + +if [ "$ACTION" = "stop" ]; then + docker rm -f "$NAME" >/dev/null 2>&1 || true + echo "Container $NAME entfernt." + exit 0 +fi +[ "$ACTION" = "start" ] || { echo "Aktion: start|stop" >&2; exit 2; } + +if [ -z "$SOURCE" ]; then + echo "Kein laufender supabase_auth_*-Container gefunden. Zuerst 'supabase start'." >&2 + exit 1 +fi + +IMAGE="$(docker inspect "$SOURCE" --format '{{.Config.Image}}')" +NETWORK="$(docker inspect "$SOURCE" --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}')" +docker rm -f "$NAME" >/dev/null 2>&1 || true + +ENV_ARGS=() +while IFS= read -r -d '' entry; do + case "$entry" in + GOTRUE_RATE_LIMIT_HEADER=*) continue ;; # wird unten gezielt gesetzt + GOTRUE_SMTP_PORT=*) [ "$PROFILE" = "smtp-down" ] && continue ;; + esac + ENV_ARGS+=(-e "$entry") +done < <(docker inspect "$SOURCE" --format '{{json .Config.Env}}' | jq -j '.[] + "\u0000"') + +case "$PROFILE" in + throttle) ENV_ARGS+=(-e "GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For") ;; + smtp-down) ENV_ARGS+=(-e "GOTRUE_SMTP_PORT=1") ;; # Host bleibt Mailpit, Port 1 lehnt die Verbindung ab +esac + +docker run -d --name "$NAME" --network "$NETWORK" -p "127.0.0.1:${HOST_PORT}:9999" \ + "${ENV_ARGS[@]}" "$IMAGE" auth >/dev/null + +for _ in $(seq 1 40); do + if curl -fsS "http://127.0.0.1:${HOST_PORT}/health" >/dev/null 2>&1; then + echo "Container $NAME ($IMAGE, Profil $PROFILE) laeuft auf http://127.0.0.1:${HOST_PORT}" + exit 0 + fi + sleep 0.5 +done +echo "Container $NAME wurde nicht gesund. Log:" >&2 +docker logs --tail 30 "$NAME" >&2 +exit 1 diff --git a/e2e/tests/api/config.spec.ts b/e2e/tests/api/config.spec.ts new file mode 100644 index 0000000..7d175ff --- /dev/null +++ b/e2e/tests/api/config.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { authConfig } from '../../support/auth-config'; + +const root = resolve(__dirname, '../../..'); +const toml = readFileSync(resolve(root, 'supabase/config.toml'), 'utf8'); +const doc = readFileSync(resolve(root, 'docs/auth-konfiguration.md'), 'utf8'); + +/** Aktive (nicht auskommentierte) Zeilen von `config.toml`. */ +const active = toml + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#')); + +function value(key: string, section?: string): string | undefined { + let current = ''; + for (const line of active) { + const sec = line.match(/^\[(.+)\]$/); + if (sec) { + current = sec[1]; + continue; + } + if (section !== undefined && current !== section) continue; + const kv = line.match(new RegExp(`^${key}\\s*=\\s*(.+)$`)); + if (kv) return kv[1].replace(/^"|"$/g, ''); + } + return undefined; +} + +test.describe('Auth-Konfiguration: auth-config.ts stimmt mit config.toml und Doku ueberein', () => { + test('AUTH-CFG-01: Site-URL', () => { + expect(value('site_url', 'auth')).toBe(authConfig.siteUrl); + }); + test('AUTH-CFG-04: E-Mail-Bestaetigung Pflicht', () => { + expect(value('enable_confirmations', 'auth.email')).toBe('true'); + }); + test('AUTH-CFG-05/-06: Passwort-Mindestlaenge und keine Zeichenklassen', () => { + expect(Number(value('minimum_password_length', 'auth'))).toBe(authConfig.passwordMinLength); + expect(value('password_requirements', 'auth')).toBe(''); + }); + test('AUTH-CFG-07: Gueltigkeit der Links', () => { + expect(Number(value('otp_expiry', 'auth.email'))).toBe(authConfig.otpExpirySeconds); + }); + test('AUTH-CFG-08: Mindestabstand lokal 1 s', () => { + expect(value('max_frequency', 'auth.email')).toBe(`${authConfig.maxFrequencySeconds}s`); + }); + test('AUTH-CFG-10/-11: Burst der Drosselung', () => { + expect(Number(value('sign_in_sign_ups', 'auth.rate_limit'))).toBe(authConfig.otpBurst); + }); + test('AUTH-CFG-14/-15: JWT-Laufzeit und Refresh-Token-Rotation', () => { + expect(Number(value('jwt_expiry', 'auth'))).toBe(authConfig.jwtExpirySeconds); + expect(value('enable_refresh_token_rotation', 'auth')).toBe('true'); + expect(Number(value('refresh_token_reuse_interval', 'auth'))).toBe(authConfig.refreshTokenReuseIntervalSeconds); + }); + test('KTO-01 Kriterium 5: AUTH-CFG-16 Sitzungsbegrenzung ist nicht gesetzt (timebox, inactivity_timeout)', () => { + expect(active.some((l) => /^timebox\s*=/.test(l))).toBe(false); + expect(active.some((l) => /^inactivity_timeout\s*=/.test(l))).toBe(false); + expect(active).not.toContain('[auth.sessions]'); + }); + test('AUTH-CFG-19: alle Benachrichtigungs-Mails aus', () => { + const sections = active.filter((l) => l.startsWith('[auth.email.notification.')); + expect(sections.length).toBeGreaterThan(0); + for (const s of sections) { + expect(value('enabled', s.slice(1, -1)), s).toBe('false'); + } + }); + test('AUTH-CFG-24: SMS-Autoconfirm aus (enable_confirmations = true), Voraussetzung fuer den Enumerationsschutz', () => { + expect(value('enable_confirmations', 'auth.sms')).toBe('true'); + }); + test('docs/auth-konfiguration.md fuehrt AUTH-CFG-01 bis -25', () => { + for (let i = 1; i <= 25; i++) { + const id = `AUTH-CFG-${String(i).padStart(2, '0')}`; + expect(doc, id).toContain(`| ${id} |`); + } + }); +}); diff --git a/e2e/tests/api/krit01-registrierung.spec.ts b/e2e/tests/api/krit01-registrierung.spec.ts new file mode 100644 index 0000000..20edf1b --- /dev/null +++ b/e2e/tests/api/krit01-registrierung.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test'; +import { subjects, TERMS_VERSION } from '../../support/auth-config'; +import { profileByEmail, userByEmail } from '../../support/db'; +import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue'; +import { extractTokenHash, waitForMail } from '../../support/mailpit'; + +const gotrue = new GoTrueClient(); + +test.describe('KTO-01 Kriterium 1: Registrierung und Bestaetigung (api)', () => { + test('KTO-01 Kriterium 1: Signup legt Nutzer und Profil an, Sitzung bleibt aus, Bestaetigungsmail im Faenger', async () => { + const email = randomEmail(); + const res = await gotrue.signUp( + email, + randomPassword(), + validMetadata({ display_name: ' Anna Beispiel ', locale: 'de' }), + ); + expect(res.status).toBe(200); + expect(res.body.id).toBeTruthy(); + expect(res.body.session ?? res.body.access_token ?? null).toBeNull(); + expect(res.body.email_confirmed_at ?? null).toBeNull(); + + const profile = await profileByEmail(email); + expect(profile).toBeTruthy(); + expect(profile.display_name).toBe('Anna Beispiel'); + expect(profile.locale).toBe('de'); + expect(profile.terms_version).toBe(TERMS_VERSION); + expect(profile.terms_accepted_at).toBeTruthy(); + expect(profile.age_confirmed_at).toBeTruthy(); + + const mail = await waitForMail(email); + expect(mail.Subject).toBe(subjects.confirmation.de); + const { type, link } = extractTokenHash(mail); + expect(type).toBe('email'); + // AUTH-CFG-01: Link baut auf der Site-URL auf. + expect(link.origin).toBe('http://localhost:3000'); + expect(link.pathname).toBe('/auth/confirm'); + }); + + test('KTO-01 Kriterium 1: Link einloesen bestaetigt die Adresse, danach ist die Anmeldung moeglich', async () => { + const email = randomEmail(); + const password = randomPassword(); + await gotrue.signUp(email, password); + const { tokenHash } = extractTokenHash(await waitForMail(email)); + + const verified = await gotrue.verify(tokenHash, 'email'); + expect(verified.status).toBe(200); + expect(verified.body.access_token).toBeTruthy(); + expect(verified.body.user.email_confirmed_at).toBeTruthy(); + + const login = await gotrue.signIn(email, password); + expect(login.status).toBe(200); + expect(login.body.access_token).toBeTruthy(); + expect((await userByEmail(email)).email_confirmed_at).toBeTruthy(); + }); + + test('KTO-01 Kriterium 1: ohne Bestaetigung kein Zugang (AUTH-CFG-04)', async () => { + const email = randomEmail(); + const password = randomPassword(); + await gotrue.signUp(email, password); + const login = await gotrue.signIn(email, password); + expect(login.status).toBe(400); + expect(login.body.error_code).toBe('email_not_confirmed'); + }); +}); diff --git a/e2e/tests/api/krit02-03-validierung.spec.ts b/e2e/tests/api/krit02-03-validierung.spec.ts new file mode 100644 index 0000000..9518f9d --- /dev/null +++ b/e2e/tests/api/krit02-03-validierung.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test'; +import { authConfig, triggerErrors } from '../../support/auth-config'; +import { userByEmail } from '../../support/db'; +import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue'; + +const gotrue = new GoTrueClient(); + +test.describe('KTO-01 Kriterium 2: Altersbestaetigung (api)', () => { + test('KTO-01 Kriterium 2: Signup ohne age_confirmed -> 500 age_confirmation_required, kein Nutzer', async () => { + const email = randomEmail(); + const res = await gotrue.signUp(email, randomPassword(), validMetadata({ age_confirmed: undefined })); + expect(res.status).toBe(500); + expect(res.body.message).toBe(triggerErrors.ageConfirmationRequired); + expect(await userByEmail(email)).toBeUndefined(); + }); + + test('KTO-01 Kriterium 2: age_confirmed=false wird ebenfalls abgelehnt', async () => { + const email = randomEmail(); + const res = await gotrue.signUp(email, randomPassword(), validMetadata({ age_confirmed: false })); + expect(res.status).toBe(500); + expect(res.body.message).toBe(triggerErrors.ageConfirmationRequired); + expect(await userByEmail(email)).toBeUndefined(); + }); +}); + +test.describe('QUE-04 Kriterium 1 / F5: Zustimmung und Version der Rechtstexte (api)', () => { + test('QUE-04 Kriterium 1: Signup ohne terms_accepted -> terms_acceptance_required', async () => { + const email = randomEmail(); + const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_accepted: undefined })); + expect(res.status).toBe(500); + expect(res.body.message).toBe(triggerErrors.termsAcceptanceRequired); + expect(await userByEmail(email)).toBeUndefined(); + }); + + test('QUE-04 Kriterium 1: Signup ohne terms_version -> terms_version_required', async () => { + const email = randomEmail(); + const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_version: undefined })); + expect(res.status).toBe(500); + expect(res.body.message).toBe(triggerErrors.termsVersionRequired); + expect(await userByEmail(email)).toBeUndefined(); + }); +}); + +test.describe('KTO-01 Kriterium 3: Passwortregeln (api)', () => { + test(`KTO-01 Kriterium 3: AUTH-CFG-05 ${authConfig.passwordMinLength - 1} Zeichen -> 422 weak_password (length)`, async () => { + const email = randomEmail(); + const res = await gotrue.signUp(email, 'a'.repeat(authConfig.passwordMinLength - 1)); + expect(res.status).toBe(422); + expect(res.body.error_code).toBe('weak_password'); + expect(res.body.weak_password?.reasons).toContain('length'); + expect(await userByEmail(email)).toBeUndefined(); + }); + + test(`KTO-01 Kriterium 3: AUTH-CFG-05/-06 genau ${authConfig.passwordMinLength} Zeichen ohne Zeichenklassen werden akzeptiert`, async () => { + const res = await gotrue.signUp(randomEmail(), 'a'.repeat(authConfig.passwordMinLength)); + expect(res.status).toBe(200); + }); +}); diff --git a/e2e/tests/api/krit04-enumeration.spec.ts b/e2e/tests/api/krit04-enumeration.spec.ts new file mode 100644 index 0000000..3408fb1 --- /dev/null +++ b/e2e/tests/api/krit04-enumeration.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@playwright/test'; +import { countUsers, profileByEmail, userByEmail } from '../../support/db'; +import { GoTrueClient, randomEmail, randomPassword, validMetadata, waitMailInterval } from '../../support/gotrue'; +import { countMails, expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit'; + +const gotrue = new GoTrueClient(); + +test.describe('KTO-01 Kriterium 4: Enumerationsschutz (api)', () => { + /** + * Regressionstest fuer AUTH-CFG-04 (MAILER_AUTOCONFIRM=false) UND AUTH-CFG-24 (SMS_AUTOCONFIRM=false). + * Mit SMS_AUTOCONFIRM=true (CLI-Standard, Self-Hosting-Vorlage) kaeme hier 422 user_already_exists. + */ + test('KTO-01 Kriterium 4: AUTH-CFG-04/-24 Signup mit bestaetigter Adresse -> 200 (nicht 422), identities leer, erfundener Nutzer, keine Mail, altes Passwort gilt', async () => { + const email = randomEmail(); + const oldPassword = randomPassword(); + const created = await gotrue.signUp(email, oldPassword); + const realId = created.body.id; + const { tokenHash } = extractTokenHash(await waitForMail(email)); + expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200); + expect(await countMails(email)).toBe(1); + + await waitMailInterval(); + const usersBefore = await countUsers(); + const newPassword = randomPassword(); + const again = await gotrue.signUp(email, newPassword, validMetadata({ display_name: 'Angreifer' })); + + expect(again.status, 'nicht 422 user_already_exists (AUTH-CFG-24)').toBe(200); + expect(again.body.error_code).toBeUndefined(); + expect(again.body.identities ?? []).toEqual([]); + expect(again.body.id).toBeTruthy(); + expect(again.body.id).not.toBe(realId); + expect(await countUsers()).toBe(usersBefore); + // Keine (weitere) Mail an die Adresse: nach festem Fenster weiterhin nur die erste Bestaetigungsmail. + await expectMailCount(email, 1); + + // Das urspruengliche Passwort gilt weiter, das neue nicht; das Profil bleibt unveraendert. + expect((await gotrue.signIn(email, oldPassword)).status).toBe(200); + const wrong = await gotrue.signIn(email, newPassword); + expect(wrong.status).toBe(400); + expect(wrong.body.error_code).toBe('invalid_credentials'); + expect((await profileByEmail(email)).display_name).toBe('E2E Tester'); + }); + + test('KTO-01 Kriterium 4: Signup mit unbestaetigter Adresse -> 200, Mail erneut an das bestehende Konto, Passwort und Profil unveraendert', async () => { + const email = randomEmail(); + const oldPassword = randomPassword(); + const first = await gotrue.signUp(email, oldPassword); + await waitForMail(email); + await waitMailInterval(); + + const newPassword = randomPassword(); + const again = await gotrue.signUp(email, newPassword, validMetadata({ display_name: 'Angreifer' })); + expect(again.status).toBe(200); + expect(again.body.id).toBe(first.body.id); + + // Die Bestaetigungsmail geht erneut an das bestehende Konto. + const second = await waitForMail(email, 2); + const { tokenHash } = extractTokenHash(second); + expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200); + + expect((await gotrue.signIn(email, oldPassword)).status).toBe(200); + expect((await gotrue.signIn(email, newPassword)).body.error_code).toBe('invalid_credentials'); + expect((await profileByEmail(email)).display_name).toBe('E2E Tester'); + expect((await userByEmail(email)).id).toBe(first.body.id); + }); + + test('KTO-01 Kriterium 4: Reset fuer unbekannte Adresse -> 200, keine Mail; fuer bekannte Adresse 200 mit Mail', async () => { + const unknown = randomEmail('unknown'); + const res = await gotrue.recover(unknown); + expect(res.status).toBe(200); + await expectMailCount(unknown, 0); + + const known = randomEmail(); + await gotrue.createConfirmedUser(known, randomPassword()); + const ok = await gotrue.recover(known); + expect(ok.status).toBe(200); + expect(ok.body).toEqual(res.body); + await waitForMail(known); + }); +}); diff --git a/e2e/tests/api/krit05-sitzung.spec.ts b/e2e/tests/api/krit05-sitzung.spec.ts new file mode 100644 index 0000000..f8630b5 --- /dev/null +++ b/e2e/tests/api/krit05-sitzung.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; +import { authConfig } from '../../support/auth-config'; +import { sessionNotAfter } from '../../support/db'; +import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue'; + +const gotrue = new GoTrueClient(); + +test.describe('KTO-01 Kriterium 5: Sitzung bleibt bestehen (api)', () => { + test('KTO-01 Kriterium 5: AUTH-CFG-14/-16 Sitzung ohne Zeitbegrenzung, JWT-Laufzeit 3600 s', async () => { + const email = randomEmail(); + const password = randomPassword(); + await gotrue.createConfirmedUser(email, password); + const login = await gotrue.signIn(email, password); + expect(login.status).toBe(200); + expect(login.body.expires_in).toBe(authConfig.jwtExpirySeconds); + // AUTH-CFG-16: weder timebox noch inactivity_timeout -> not_after bleibt leer. + expect(await sessionNotAfter(email)).toEqual([null]); + }); + + test('KTO-01 Kriterium 5: AUTH-CFG-15 Refresh liefert neue Tokens, die Sitzung bleibt gueltig', async () => { + const email = randomEmail(); + const password = randomPassword(); + await gotrue.createConfirmedUser(email, password); + const login = await gotrue.signIn(email, password); + const refreshed = await gotrue.refresh(login.body.refresh_token); + expect(refreshed.status).toBe(200); + expect(refreshed.body.refresh_token).not.toBe(login.body.refresh_token); + expect((await gotrue.getUser(refreshed.body.access_token)).status).toBe(200); + }); +}); diff --git a/e2e/tests/api/krit06-07-anmeldung.spec.ts b/e2e/tests/api/krit06-07-anmeldung.spec.ts new file mode 100644 index 0000000..79528ea --- /dev/null +++ b/e2e/tests/api/krit06-07-anmeldung.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test'; +import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue'; +import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit'; + +const gotrue = new GoTrueClient(); + +test.describe('KTO-01 Kriterium 6: unbestaetigtes Konto (api)', () => { + test('KTO-01 Kriterium 6: richtiges Passwort -> email_not_confirmed, falsches Passwort -> invalid_credentials', async () => { + const email = randomEmail(); + const password = randomPassword(); + await gotrue.signUp(email, password); + + const right = await gotrue.signIn(email, password); + expect(right.status).toBe(400); + expect(right.body.error_code).toBe('email_not_confirmed'); + + const wrong = await gotrue.signIn(email, `${password}-falsch`); + expect(wrong.status).toBe(400); + expect(wrong.body.error_code).toBe('invalid_credentials'); + }); + + test('KTO-01 Kriterium 6: erneut senden -> zweite Mail, deren Link das Konto bestaetigt (AUTH-CFG-08: lokal 1 s)', async () => { + const email = randomEmail(); + const password = randomPassword(); + await gotrue.signUp(email, password); + await waitForMail(email); + await waitMailInterval(); + + const resend = await gotrue.resend(email); + expect(resend.status).toBe(200); + const second = await waitForMail(email, 2); + const { tokenHash } = extractTokenHash(second); + expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200); + expect((await gotrue.signIn(email, password)).status).toBe(200); + }); + + test('KTO-01 Kriterium 6: erneut senden fuer unbekannte Adresse -> 200, keine Mail (keine Enumeration)', async () => { + const unknown = randomEmail('unknown'); + const res = await gotrue.resend(unknown); + expect(res.status).toBe(200); + await expectMailCount(unknown, 0); + }); +}); + +test.describe('KTO-01 Kriterium 7: keine Auskunft ueber Konten (api)', () => { + test('KTO-01 Kriterium 7: falsche E-Mail und falsches Passwort liefern identische Antworten', async () => { + const email = randomEmail(); + const password = randomPassword(); + await gotrue.createConfirmedUser(email, password); + + const wrongPassword = await gotrue.signIn(email, `${password}-falsch`); + const wrongEmail = await gotrue.signIn(randomEmail('unknown'), password); + expect(wrongPassword.status).toBe(400); + expect(wrongEmail.status).toBe(wrongPassword.status); + expect(wrongEmail.body.error_code).toBe('invalid_credentials'); + expect(wrongEmail.body).toEqual(wrongPassword.body); + }); +}); diff --git a/e2e/tests/api/krit08-reset-link.spec.ts b/e2e/tests/api/krit08-reset-link.spec.ts new file mode 100644 index 0000000..ddea037 --- /dev/null +++ b/e2e/tests/api/krit08-reset-link.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from '@playwright/test'; +import { authConfig, errorCodes } from '../../support/auth-config'; +import { expireConfirmationLink, expireRecoveryLink } from '../../support/db'; +import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue'; +import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit'; + +const gotrue = new GoTrueClient(); + +async function confirmedUser() { + const email = randomEmail(); + const password = randomPassword(); + const created = await gotrue.createConfirmedUser(email, password); + expect(created.status).toBe(200); + return { email, password }; +} + +test.describe('KTO-01 Kriterium 8: Passwort-Reset-Link (api)', () => { + test('KTO-01 Kriterium 8: Reset anfordern -> Mail mit type=recovery -> einloesen ok -> zweites Einloesen 403 otp_expired', async () => { + const { email } = await confirmedUser(); + expect((await gotrue.recover(email)).status).toBe(200); + const { tokenHash, type } = extractTokenHash(await waitForMail(email)); + expect(type).toBe('recovery'); + + const first = await gotrue.verify(tokenHash, 'recovery'); + expect(first.status).toBe(200); + expect(first.body.access_token).toBeTruthy(); + + const second = await gotrue.verify(tokenHash, 'recovery'); + expect(second.status).toBe(403); + expect(second.body.error_code).toBe(errorCodes.otpExpired); + }); + + test('KTO-01 Kriterium 8: neue Reset-Anforderung ersetzt den alten Link', async () => { + const { email } = await confirmedUser(); + await gotrue.recover(email); + const oldLink = extractTokenHash(await waitForMail(email)); + await waitMailInterval(); + await gotrue.recover(email); + const newLink = extractTokenHash(await waitForMail(email, 2)); + expect(newLink.tokenHash).not.toBe(oldLink.tokenHash); + + const old = await gotrue.verify(oldLink.tokenHash, 'recovery'); + expect(old.status).toBe(403); + expect(old.body.error_code).toBe(errorCodes.otpExpired); + expect((await gotrue.verify(newLink.tokenHash, 'recovery')).status).toBe(200); + }); + + test('KTO-01 Kriterium 8: AUTH-CFG-07 Reset-Link ist nach 1 h + 1 s abgelaufen (recovery_sent_at zurueckgesetzt)', async () => { + const { email } = await confirmedUser(); + await gotrue.recover(email); + const { tokenHash } = extractTokenHash(await waitForMail(email)); + await expireRecoveryLink(email); + + const res = await gotrue.verify(tokenHash, 'recovery'); + expect(res.status).toBe(403); + expect(res.body.error_code).toBe(errorCodes.otpExpired); + }); + + test('KTO-01 Kriterium 8: AUTH-CFG-07 Grenze: Reset-Link kurz vor Ablauf ist noch gueltig', async () => { + const { email } = await confirmedUser(); + await gotrue.recover(email); + const { tokenHash } = extractTokenHash(await waitForMail(email)); + await expireRecoveryLink(email, authConfig.otpExpirySeconds - 60); + expect((await gotrue.verify(tokenHash, 'recovery')).status).toBe(200); + }); + + test('KTO-01 Kriterium 8: AUTH-CFG-07 Bestaetigungslink ist nach 1 h + 1 s abgelaufen (confirmation_sent_at zurueckgesetzt)', async () => { + const email = randomEmail(); + const password = randomPassword(); + await gotrue.signUp(email, password); + const { tokenHash } = extractTokenHash(await waitForMail(email)); + await expireConfirmationLink(email); + + const res = await gotrue.verify(tokenHash, 'email'); + expect(res.status).toBe(403); + expect(res.body.error_code).toBe(errorCodes.otpExpired); + // Konto bleibt unbestaetigt. + expect((await gotrue.signIn(email, password)).body.error_code).toBe(errorCodes.emailNotConfirmed); + }); + + test('KTO-01 Kriterium 8: Bestaetigungslink ist einmalig', async () => { + const email = randomEmail(); + await gotrue.signUp(email, randomPassword()); + const { tokenHash } = extractTokenHash(await waitForMail(email)); + expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200); + const again = await gotrue.verify(tokenHash, 'email'); + expect(again.status).toBe(403); + expect(again.body.error_code).toBe(errorCodes.otpExpired); + }); + + test('KTO-01 Kriterium 8: Reset fuer unbekannte Adresse -> 200, keine Mail', async () => { + const unknown = randomEmail('unknown'); + expect((await gotrue.recover(unknown)).status).toBe(200); + await expectMailCount(unknown, 0); + }); + + test('KTO-01 Kriterium 8: nach dem Setzen des neuen Passworts ist der Reset-Link verbraucht', async () => { + const { email } = await confirmedUser(); + await gotrue.recover(email); + const { tokenHash } = extractTokenHash(await waitForMail(email)); + const session = await gotrue.verify(tokenHash, 'recovery'); + const update = await gotrue.updateUser(session.body.access_token, { password: randomPassword() }); + expect(update.status).toBe(200); + expect((await gotrue.verify(tokenHash, 'recovery')).body.error_code).toBe(errorCodes.otpExpired); + }); +}); diff --git a/e2e/tests/api/krit09-abmeldung.spec.ts b/e2e/tests/api/krit09-abmeldung.spec.ts new file mode 100644 index 0000000..5171ba4 --- /dev/null +++ b/e2e/tests/api/krit09-abmeldung.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from '@playwright/test'; +import { env, errorCodes } from '../../support/auth-config'; +import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue'; +import { extractTokenHash, waitForMail } from '../../support/mailpit'; + +const gotrue = new GoTrueClient(); + +async function resetSession(email: string) { + await gotrue.recover(email); + const { tokenHash } = extractTokenHash(await waitForMail(email)); + const res = await gotrue.verify(tokenHash, 'recovery'); + expect(res.status).toBe(200); + return res.body.access_token as string; +} + +test.describe('KTO-01 Kriterium 9: Passwort aendern beendet andere Sitzungen (api)', () => { + test('KTO-01 Kriterium 9: nach Reset sind die Refresh-Tokens anderer Sitzungen ungueltig, die Reset-Sitzung bleibt', async () => { + const email = randomEmail(); + const oldPassword = randomPassword(); + const newPassword = randomPassword(); + await gotrue.createConfirmedUser(email, oldPassword); + + const a = (await gotrue.signIn(email, oldPassword)).body; + const b = (await gotrue.signIn(email, oldPassword)).body; + expect(a.refresh_token).not.toBe(b.refresh_token); + + const recoveryToken = await resetSession(email); + const update = await gotrue.updateUser(recoveryToken, { password: newPassword }); + expect(update.status).toBe(200); + + for (const [name, session] of [['A', a], ['B', b]] as const) { + const refreshed = await gotrue.refresh(session.refresh_token); + expect(refreshed.status, `Sitzung ${name}`).toBeGreaterThanOrEqual(400); + expect(['refresh_token_not_found', 'session_not_found']).toContain( + refreshed.body.error_code, + ); + } + + // Neues Passwort gilt, altes nicht. + expect((await gotrue.signIn(email, newPassword)).status).toBe(200); + expect((await gotrue.signIn(email, oldPassword)).body.error_code).toBe(errorCodes.invalidCredentials); + }); + + test('KTO-01 Kriterium 9 / R8: GoTrue /user lehnt das Access-Token der beendeten Sitzung ab (PostgREST nicht: bis zu 60 min, F1)', async () => { + const email = randomEmail(); + const oldPassword = randomPassword(); + await gotrue.createConfirmedUser(email, oldPassword); + const a = (await gotrue.signIn(email, oldPassword)).body; + const recoveryToken = await resetSession(email); + await gotrue.updateUser(recoveryToken, { password: randomPassword() }); + + const user = await gotrue.getUser(a.access_token); + expect([401, 403]).toContain(user.status); + + // Dokumentiert das akzeptierte Restrisiko R8 (AUTH-CFG-14): PostgREST prueft keine Sitzungen. + const rest = await fetch(`${env.supabaseUrl}/rest/v1/profiles?select=id`, { + headers: { apikey: env.anonKey, authorization: `Bearer ${a.access_token}` }, + }); + expect(rest.status).toBe(200); + }); + + test('KTO-01 Kriterium 9: gleiches Passwort -> 422 same_password, zu kurzes -> 422 weak_password', async () => { + const email = randomEmail(); + const password = randomPassword(); + await gotrue.createConfirmedUser(email, password); + const token = await resetSession(email); + + const same = await gotrue.updateUser(token, { password }); + expect(same.status).toBe(422); + expect(same.body.error_code).toBe(errorCodes.samePassword); + + const weak = await gotrue.updateUser(token, { password: 'kurz' }); + expect(weak.status).toBe(422); + expect(weak.body.error_code).toBe(errorCodes.weakPassword); + }); +}); diff --git a/e2e/tests/api/krit10-anzeigename.spec.ts b/e2e/tests/api/krit10-anzeigename.spec.ts new file mode 100644 index 0000000..7f5be97 --- /dev/null +++ b/e2e/tests/api/krit10-anzeigename.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test'; +import { authConfig, triggerErrors } from '../../support/auth-config'; +import { profileByEmail, userByEmail } from '../../support/db'; +import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue'; + +const gotrue = new GoTrueClient(); + +test.describe('KTO-01 Kriterium 10: Anzeigename (api)', () => { + test('KTO-01 Kriterium 10: 31 Zeichen -> HTTP 500, Body {"code":"P0001","message":"display_name_invalid"}, kein Nutzer', async () => { + const email = randomEmail(); + const res = await gotrue.signUp( + email, + randomPassword(), + validMetadata({ display_name: 'a'.repeat(authConfig.displayNameMaxLength + 1) }), + ); + // Format aus Spike B0 (f), Mapping-Regel 4.4: maßgeblich ist HTTP 500 beim signUp. + expect(res.status).toBe(500); + expect(res.body).toEqual({ code: 'P0001', message: triggerErrors.displayNameInvalid }); + expect(res.headers.get('x-sb-error-code')).toBe('unexpected_failure'); + expect(await userByEmail(email)).toBeUndefined(); + }); + + for (const [label, value] of [ + ['fehlend', undefined], + ['leer', ''], + ['nur Leerzeichen', ' '], + ] as const) { + test(`KTO-01 Kriterium 10: Anzeigename ${label} -> display_name_invalid`, async () => { + const email = randomEmail(); + const res = await gotrue.signUp(email, randomPassword(), validMetadata({ display_name: value })); + expect(res.status).toBe(500); + expect(res.body.message).toBe(triggerErrors.displayNameInvalid); + expect(await userByEmail(email)).toBeUndefined(); + }); + } + + test('KTO-01 Kriterium 10: genau 30 Zeichen werden akzeptiert', async () => { + const email = randomEmail(); + const name = 'b'.repeat(authConfig.displayNameMaxLength); + const res = await gotrue.signUp(email, randomPassword(), validMetadata({ display_name: name })); + expect(res.status).toBe(200); + expect((await profileByEmail(email)).display_name).toBe(name); + }); + + test('KTO-01 Kriterium 10: Codepoints zaehlen, nicht UTF-16-Einheiten (30 Emoji ok, 31 abgelehnt)', async () => { + const ok = randomEmail(); + const accepted = await gotrue.signUp(ok, randomPassword(), validMetadata({ display_name: '😀'.repeat(30) })); + expect(accepted.status).toBe(200); + expect([...(await profileByEmail(ok)).display_name]).toHaveLength(30); + + const bad = randomEmail(); + const rejected = await gotrue.signUp(bad, randomPassword(), validMetadata({ display_name: '😀'.repeat(31) })); + expect(rejected.status).toBe(500); + expect(rejected.body.message).toBe(triggerErrors.displayNameInvalid); + }); + + test('KTO-01 Kriterium 10: Anzeigename wird getrimmt gespeichert; 30 Zeichen plus Rand-Leerzeichen sind zulaessig', async () => { + const email = randomEmail(); + const name = 'c'.repeat(authConfig.displayNameMaxLength); + const res = await gotrue.signUp(email, randomPassword(), validMetadata({ display_name: ` ${name} ` })); + expect(res.status).toBe(200); + expect((await profileByEmail(email)).display_name).toBe(name); + }); +}); diff --git a/e2e/tests/api/krit11-sprache-benachrichtigungen.spec.ts b/e2e/tests/api/krit11-sprache-benachrichtigungen.spec.ts new file mode 100644 index 0000000..f1df705 --- /dev/null +++ b/e2e/tests/api/krit11-sprache-benachrichtigungen.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from '@playwright/test'; +import { env, subjects } from '../../support/auth-config'; +import { GoTrueClient, randomEmail, randomPassword, validMetadata, waitMailInterval } from '../../support/gotrue'; +import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit'; + +const gotrue = new GoTrueClient(); + +test.describe('KTO-01 Kriterium 11: Mails nur in der Profilsprache, nur die zwei Mailarten (api)', () => { + test('KTO-01 Kriterium 11: AUTH-CFG-17 locale=de -> deutsche Bestaetigungsmail', async () => { + const email = randomEmail(); + await gotrue.signUp(email, randomPassword(), validMetadata({ locale: 'de' })); + const mail = await waitForMail(email); + expect(mail.Subject).toBe(subjects.confirmation.de); + expect(mail.html).toContain(''); + }); + + test('KTO-01 Kriterium 11: AUTH-CFG-17 locale=en -> englische Bestaetigungsmail', async () => { + const email = randomEmail(); + await gotrue.signUp(email, randomPassword(), validMetadata({ locale: 'en' })); + const mail = await waitForMail(email); + expect(mail.Subject).toBe(subjects.confirmation.en); + expect(mail.html).toContain(''); + }); + + test('KTO-01 Kriterium 11: ohne locale -> Englisch (F6)', async () => { + const email = randomEmail(); + await gotrue.signUp(email, randomPassword(), validMetadata({ locale: undefined })); + const mail = await waitForMail(email); + expect(mail.Subject).toBe(subjects.confirmation.en); + }); + + test('KTO-01 Kriterium 11: AUTH-CFG-18 Profilsprache auf en aendern -> Reset-Mail englisch; vorher deutsch', async () => { + const email = randomEmail(); + const password = randomPassword(); + const created = await gotrue.createConfirmedUser(email, password, validMetadata({ locale: 'de' })); + const userId = created.body.id as string; + + await gotrue.recover(email); + const german = await waitForMail(email); + expect(german.Subject).toBe(subjects.recovery.de); + expect(german.html).toContain(''); + expect(extractTokenHash(german).type).toBe('recovery'); + + // Profilsprache wie die App ueber PostgREST mit dem Token des Nutzers aendern (Sync-Trigger -> user_metadata). + const token = (await gotrue.signIn(email, password)).body.access_token as string; + const patch = await fetch(`${env.supabaseUrl}/rest/v1/profiles?id=eq.${userId}`, { + method: 'PATCH', + headers: { + apikey: env.anonKey, + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + prefer: 'return=representation', + }, + body: JSON.stringify({ locale: 'en' }), + }); + expect(patch.status).toBe(200); + expect((await patch.json())[0].locale).toBe('en'); + + await waitMailInterval(); + await gotrue.recover(email); + const english = await waitForMail(email, 2); + expect(english.Subject).toBe(subjects.recovery.en); + expect(english.html).toContain(''); + }); + + test('KTO-01 Kriterium 11: AUTH-CFG-19 nach Reset und Login keine weitere Mail (Benachrichtigungen aus)', async () => { + const email = randomEmail(); + const password = randomPassword(); + await gotrue.createConfirmedUser(email, password); + + await gotrue.recover(email); + const { tokenHash } = extractTokenHash(await waitForMail(email)); + const session = await gotrue.verify(tokenHash, 'recovery'); + const newPassword = randomPassword(); + expect((await gotrue.updateUser(session.body.access_token, { password: newPassword })).status).toBe(200); + expect((await gotrue.signIn(email, newPassword)).status).toBe(200); + + // Genau die eine Reset-Mail; keine password_changed-Benachrichtigung. + await expectMailCount(email, 1); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..a399888 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["support", "tests", "playwright.config.ts"] +}