diff --git a/backend/package-lock.json b/backend/package-lock.json index e1d9413..69e1b09 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@aws-sdk/client-ses": "^3.930.0", "@prisma/client": "^5.8.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", @@ -26,6 +27,639 @@ "supertest": "^6.3.3" } }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-ses": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.930.0.tgz", + "integrity": "sha512-N0IPBfFnXNv4VrVsS1+JcdyA0nl+8NTz8CCRlcUuRhwxyIFhL7KkMnTRPVrJ4ppchGbITnbp52v3c5DWWAQpTQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.930.0", + "@aws-sdk/credential-provider-node": "3.930.0", + "@aws-sdk/middleware-host-header": "3.930.0", + "@aws-sdk/middleware-logger": "3.930.0", + "@aws-sdk/middleware-recursion-detection": "3.930.0", + "@aws-sdk/middleware-user-agent": "3.930.0", + "@aws-sdk/region-config-resolver": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@aws-sdk/util-endpoints": "3.930.0", + "@aws-sdk/util-user-agent-browser": "3.930.0", + "@aws-sdk/util-user-agent-node": "3.930.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.2", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.9", + "@smithy/middleware-retry": "^4.4.9", + "@smithy/middleware-serde": "^4.2.5", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.5", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.8", + "@smithy/util-defaults-mode-node": "^4.2.11", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.930.0.tgz", + "integrity": "sha512-sASqgm1iMLcmi+srSH9WJuqaf3GQAKhuB4xIJwkNEPUQ+yGV8HqErOOHJLXXuTUyskcdtK+4uMaBRLT2ESm+QQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.930.0", + "@aws-sdk/middleware-host-header": "3.930.0", + "@aws-sdk/middleware-logger": "3.930.0", + "@aws-sdk/middleware-recursion-detection": "3.930.0", + "@aws-sdk/middleware-user-agent": "3.930.0", + "@aws-sdk/region-config-resolver": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@aws-sdk/util-endpoints": "3.930.0", + "@aws-sdk/util-user-agent-browser": "3.930.0", + "@aws-sdk/util-user-agent-node": "3.930.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.2", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.9", + "@smithy/middleware-retry": "^4.4.9", + "@smithy/middleware-serde": "^4.2.5", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.5", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.8", + "@smithy/util-defaults-mode-node": "^4.2.11", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.930.0.tgz", + "integrity": "sha512-E95pWT1ayfRWg0AW2KNOCYM7QQcVeOhMRLX5PXLeDKcdxP7s3x0LHG9t7a3nPbAbvYLRrhC7O2lLWzzMCpqjsw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@aws-sdk/xml-builder": "3.930.0", + "@smithy/core": "^3.18.2", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/smithy-client": "^4.9.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.930.0.tgz", + "integrity": "sha512-5tJyxNQmm9C1XKeiWt/K67mUHtTiU2FxTkVsqVrzAMjNsF3uyA02kyTK70byh5n29oVR9XNValVEl6jk01ipYg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.930.0.tgz", + "integrity": "sha512-vw565GctpOPoRJyRvgqXM8U/4RG8wYEPfhe6GHvt9dchebw0OaFeW1mmSYpwEPkMhZs9Z808dkSPScwm8WZBKA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.5", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.930.0.tgz", + "integrity": "sha512-Ua4T5MWjm7QdHi7ZSUvnPBFwBZmLFP/IEGCLacPKbUT1sQO30hlWuB/uQOj0ns4T6p7V4XsM8bz5+xsW2yRYbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/credential-provider-env": "3.930.0", + "@aws-sdk/credential-provider-http": "3.930.0", + "@aws-sdk/credential-provider-process": "3.930.0", + "@aws-sdk/credential-provider-sso": "3.930.0", + "@aws-sdk/credential-provider-web-identity": "3.930.0", + "@aws-sdk/nested-clients": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.930.0.tgz", + "integrity": "sha512-LTx5G0PsL51hNCCzOIdacGPwqnTp3X2Ck8CjLL4Kz9FTR0mfY02qEJB5y5segU1hlge/WdQYxzBBMhtMUR2h8A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.930.0", + "@aws-sdk/credential-provider-http": "3.930.0", + "@aws-sdk/credential-provider-ini": "3.930.0", + "@aws-sdk/credential-provider-process": "3.930.0", + "@aws-sdk/credential-provider-sso": "3.930.0", + "@aws-sdk/credential-provider-web-identity": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.930.0.tgz", + "integrity": "sha512-lqC4lepxgwR2uZp/JROTRjkHld4/FEpSgofmiIOAfUfDx0OWSg7nkWMMS/DzlMpODqATl9tO0DcvmIJ8tMbh6g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.930.0.tgz", + "integrity": "sha512-LIs2aaVoFfioRokR1R9SpLS9u8CmbHhrV/gpHO1ED41qNCujn23vAxRNQmWzJ2XoCxSTwvToiHD2i6CjPA6rHQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.930.0", + "@aws-sdk/core": "3.930.0", + "@aws-sdk/token-providers": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.930.0.tgz", + "integrity": "sha512-iIYF8GReLOp16yn2bnRWrc4UOW/vVLifqyRWZ3iAGe8NFzUiHBq+Nok7Edh+2D8zt30QOCOsWCZ31uRrPuXH8w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/nested-clients": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.930.0.tgz", + "integrity": "sha512-x30jmm3TLu7b/b+67nMyoV0NlbnCVT5DI57yDrhXAPCtdgM1KtdLWt45UcHpKOm1JsaIkmYRh2WYu7Anx4MG0g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.930.0.tgz", + "integrity": "sha512-vh4JBWzMCBW8wREvAwoSqB2geKsZwSHTa0nSt0OMOLp2PdTYIZDi0ZiVMmpfnjcx9XbS6aSluLv9sKx4RrG46A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.930.0.tgz", + "integrity": "sha512-gv0sekNpa2MBsIhm2cjP3nmYSfI4nscx/+K9u9ybrWZBWUIC4kL2sV++bFjjUz4QxUIlvKByow3/a9ARQyCu7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@aws/lambda-invoke-store": "^0.1.1", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.930.0.tgz", + "integrity": "sha512-UUItqy02biaHoZDd1Z2CskFon3Lej15ZCIZzW4n2lsJmgLWNvz21jtFA8DQny7ZgCLAOOXI8YK3VLZptZWtIcg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@aws-sdk/util-endpoints": "3.930.0", + "@smithy/core": "^3.18.2", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.930.0.tgz", + "integrity": "sha512-eEDjTVXNiDkoV0ZV+X+WV40GTpF70xZmDW13CQzQF7rzOC2iFjtTRU+F7MUhy/Vs+e9KvDgiuCDecITtaOXUNw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.930.0", + "@aws-sdk/middleware-host-header": "3.930.0", + "@aws-sdk/middleware-logger": "3.930.0", + "@aws-sdk/middleware-recursion-detection": "3.930.0", + "@aws-sdk/middleware-user-agent": "3.930.0", + "@aws-sdk/region-config-resolver": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@aws-sdk/util-endpoints": "3.930.0", + "@aws-sdk/util-user-agent-browser": "3.930.0", + "@aws-sdk/util-user-agent-node": "3.930.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.2", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.9", + "@smithy/middleware-retry": "^4.4.9", + "@smithy/middleware-serde": "^4.2.5", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.5", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.8", + "@smithy/util-defaults-mode-node": "^4.2.11", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.930.0.tgz", + "integrity": "sha512-KL2JZqH6aYeQssu1g1KuWsReupdfOoxD6f1as2VC+rdwYFUu4LfzMsFfXnBvvQWWqQ7rZHWOw1T+o5gJmg7Dzw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.930.0.tgz", + "integrity": "sha512-K+fJFJXA2Tdx10WhhTm+xQmf1WDHu14rUutByyqx6W0iW2rhtl3YeRr188LWSU3/hpz7BPyvigaAb0QyRti6FQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.930.0", + "@aws-sdk/nested-clients": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.930.0.tgz", + "integrity": "sha512-we/vaAgwlEFW7IeftmCLlLMw+6hFs3DzZPJw7lVHbj/5HJ0bz9gndxEsS2lQoeJ1zhiiLqAqvXxmM43s0MBg0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.930.0.tgz", + "integrity": "sha512-M2oEKBzzNAYr136RRc6uqw3aWlwCxqTP1Lawps9E1d2abRPvl1p1ztQmmXp1Ak4rv8eByIZ+yQyKQ3zPdRG5dw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-endpoints": "^3.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.893.0.tgz", + "integrity": "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.930.0.tgz", + "integrity": "sha512-q6lCRm6UAe+e1LguM5E4EqM9brQlDem4XDcQ87NzEvlTW6GzmNCO0w1jS0XgCFXQHjDxjdlNFX+5sRbHijwklg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.930.0", + "@smithy/types": "^4.9.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.930.0.tgz", + "integrity": "sha512-tYc5uFKogn0vLukeZ6Zz2dR1/WiTjxZH7+Jjoce6aEYgRVfyrDje1POFb7YxhNZ7Pp1WzHCuwW2KgkmMoYVbxQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.930.0", + "@aws-sdk/types": "3.930.0", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", + "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.1.1.tgz", + "integrity": "sha512-RcLam17LdlbSOSp9VxmUu1eI6Mwxp+OwhD2QhiSNmNCzoDb0EeUXTD2n/WbcnrAYMGlmf05th6QYq23VqvJqpA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1079,6 +1713,600 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz", + "integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.3.tgz", + "integrity": "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.3.tgz", + "integrity": "sha512-qqpNskkbHOSfrbFbjhYj5o8VMXO26fvN1K/+HbCzUNlTuxgNcPRouUDNm+7D6CkN244WG7aK533Ne18UtJEgAA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-stream": "^4.5.6", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.5.tgz", + "integrity": "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz", + "integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.5.tgz", + "integrity": "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.5.tgz", + "integrity": "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.5.tgz", + "integrity": "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.3.10", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.10.tgz", + "integrity": "sha512-SoAag3QnWBFoXjwa1jenEThkzJYClidZUyqsLKwWZ8kOlZBwehrLBp4ygVDjNEM2a2AamCQ2FBA/HuzKJ/LiTA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.18.3", + "@smithy/middleware-serde": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-middleware": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.10.tgz", + "integrity": "sha512-6fOwX34gXxcqKa3bsG0mR0arc2Cw4ddOS6tp3RgUD2yoTrDTbQ2aVADnDjhUuxaiDZN2iilxndgGDhnpL/XvJA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/service-error-classification": "^4.2.5", + "@smithy/smithy-client": "^4.9.6", + "@smithy/types": "^4.9.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.5.tgz", + "integrity": "sha512-La1ldWTJTZ5NqQyPqnCNeH9B+zjFhrNoQIL1jTh4zuqXRlmXhxYHhMtI1/92OlnoAtp6JoN7kzuwhWoXrBwPqg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz", + "integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz", + "integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz", + "integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz", + "integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz", + "integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz", + "integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz", + "integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.5.tgz", + "integrity": "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz", + "integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.5.tgz", + "integrity": "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.6.tgz", + "integrity": "sha512-hGz42hggqReicRRZUvrKDQiAmoJnx1Q+XfAJnYAGu544gOfxQCAC3hGGD7+Px2gEUUxB/kKtQV7LOtBRNyxteQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.18.3", + "@smithy/middleware-endpoint": "^4.3.10", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz", + "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz", + "integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.9.tgz", + "integrity": "sha512-Bh5bU40BgdkXE2BcaNazhNtEXi1TC0S+1d84vUwv5srWfvbeRNUKFzwKQgC6p6MXPvEgw+9+HdX3pOwT6ut5aw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.6", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.12.tgz", + "integrity": "sha512-EHZwe1E9Q7umImIyCKQg/Cm+S+7rjXxCRvfGmKifqwYvn7M8M4ZcowwUOQzvuuxUUmdzCkqL0Eq0z1m74Pq6pw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.3", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.6", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.5.tgz", + "integrity": "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz", + "integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.5.tgz", + "integrity": "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz", + "integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.5.tgz", + "integrity": "sha512-Dbun99A3InifQdIrsXZ+QLcC0PGBPAdrl4cj1mTgJvyc9N2zf7QSxg8TBkzsCmGJdE3TLbO9ycwpY0EkWahQ/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -1491,6 +2719,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/bowser": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", + "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "dev": true, @@ -2381,6 +3615,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -4919,6 +6171,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/superagent": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", @@ -5067,6 +6331,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", diff --git a/backend/package.json b/backend/package.json index fbeef34..2d0b73b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,6 +23,7 @@ "author": "", "license": "ISC", "dependencies": { + "@aws-sdk/client-ses": "^3.930.0", "@prisma/client": "^5.8.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", diff --git a/backend/prisma/migrations/20251113151534_add_wsdc_and_email_verification/migration.sql b/backend/prisma/migrations/20251113151534_add_wsdc_and_email_verification/migration.sql new file mode 100644 index 0000000..b7c43d0 --- /dev/null +++ b/backend/prisma/migrations/20251113151534_add_wsdc_and_email_verification/migration.sql @@ -0,0 +1,23 @@ +-- AlterTable: Add WSDC Integration fields +ALTER TABLE "users" ADD COLUMN "first_name" VARCHAR(100), +ADD COLUMN "last_name" VARCHAR(100), +ADD COLUMN "wsdc_id" VARCHAR(20); + +-- AlterTable: Add Email Verification fields +ALTER TABLE "users" ADD COLUMN "email_verified" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "verification_token" VARCHAR(255), +ADD COLUMN "verification_code" VARCHAR(6), +ADD COLUMN "verification_token_expiry" TIMESTAMP(3); + +-- AlterTable: Add Password Reset fields +ALTER TABLE "users" ADD COLUMN "reset_token" VARCHAR(255), +ADD COLUMN "reset_token_expiry" TIMESTAMP(3); + +-- CreateIndex +CREATE UNIQUE INDEX "users_wsdc_id_key" ON "users"("wsdc_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_verification_token_key" ON "users"("verification_token"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_reset_token_key" ON "users"("reset_token"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 29ceb9a..f6db193 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -12,20 +12,36 @@ datasource db { // Users table model User { - id Int @id @default(autoincrement()) - username String @unique @db.VarChar(50) - email String @unique @db.VarChar(255) - passwordHash String @map("password_hash") @db.VarChar(255) - avatar String? @db.VarChar(255) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + username String @unique @db.VarChar(50) + email String @unique @db.VarChar(255) + passwordHash String @map("password_hash") @db.VarChar(255) + + // WSDC Integration (Phase 1.5) + firstName String? @map("first_name") @db.VarChar(100) + lastName String? @map("last_name") @db.VarChar(100) + wsdcId String? @unique @map("wsdc_id") @db.VarChar(20) + + // Email Verification (Phase 1.5) + emailVerified Boolean @default(false) @map("email_verified") + verificationToken String? @unique @map("verification_token") @db.VarChar(255) + verificationCode String? @map("verification_code") @db.VarChar(6) + verificationTokenExpiry DateTime? @map("verification_token_expiry") + + // Password Reset (Phase 1.5) + resetToken String? @unique @map("reset_token") @db.VarChar(255) + resetTokenExpiry DateTime? @map("reset_token_expiry") + + avatar String? @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") // Relations - messages Message[] - matchesAsUser1 Match[] @relation("MatchUser1") - matchesAsUser2 Match[] @relation("MatchUser2") - ratingsGiven Rating[] @relation("RaterRatings") - ratingsReceived Rating[] @relation("RatedRatings") + messages Message[] + matchesAsUser1 Match[] @relation("MatchUser1") + matchesAsUser2 Match[] @relation("MatchUser2") + ratingsGiven Rating[] @relation("RaterRatings") + ratingsReceived Rating[] @relation("RatedRatings") @@map("users") } diff --git a/backend/src/__tests__/TESTS_README.md b/backend/src/__tests__/TESTS_README.md new file mode 100644 index 0000000..0254870 --- /dev/null +++ b/backend/src/__tests__/TESTS_README.md @@ -0,0 +1,225 @@ +# Unit Tests - Phase 1.5 + +This directory contains comprehensive unit tests for Phase 1.5 features: Email Verification, Password Reset, and WSDC Integration. + +## Test Overview + +### ✅ Test Suite Summary + +| Test Suite | Tests | Coverage | Status | +|------------|-------|----------|--------| +| Auth Utils | 18 | 100% | ✅ | +| Email Service | 22 | 100% | ✅ | +| WSDC Controller | 13 | 100% | ✅ | +| Auth Middleware | 11 | 42%* | ✅ | +| **TOTAL** | **65** | - | **✅** | + +*Note: 42% coverage is for auth.js middleware file which includes the original `authenticate` middleware (not tested here). The new `requireEmailVerification` middleware has 100% coverage. + +## Test Files + +### Unit Tests (No Database Required) + +- **`utils/auth.test.js`** - Authentication utilities + - Token generation (verification, reset) + - PIN code generation + - Token expiry calculation + - Password hashing (existing) + - JWT tokens (existing) + +- **`utils/email.test.js`** - Email service with AWS SES mocks + - Email sending functionality + - Verification email (link + code) + - Password reset email + - Welcome email + - Error handling + +- **`wsdc.test.js`** - WSDC API proxy + - Dancer lookup by ID + - Input validation + - Error handling + - API integration + +- **`middleware/auth.test.js`** - Auth middleware + - `requireEmailVerification` function + - Email verification checks + - Authorization flow + +### Integration Tests (Database Required) + +- **`auth-phase1.5.test.js`** - Full auth flow integration + - Registration with WSDC data + - Email verification (token + code) + - Resend verification + - Password reset request + - Password reset with token + - Database interactions + +## Running Tests + +### Unit Tests Only (Fast, No Database) + +```bash +# From backend directory +npm test -- --testPathPattern="utils/|wsdc.test|middleware/" +``` + +This runs all unit tests that don't require database connection. Perfect for quick development feedback. + +### All Tests (Including Integration) + +```bash +# Start Docker containers first +docker compose up -d + +# Run all tests +docker compose exec backend npm test + +# Or from backend directory (if database is running) +npm test +``` + +### Watch Mode (Development) + +```bash +npm test -- --watch --testPathPattern="utils/email" +``` + +## Test Coverage + +```bash +# Generate coverage report +npm test -- --coverage + +# Coverage for Phase 1.5 only +npm test -- --coverage --testPathPattern="utils/|wsdc.test|middleware/|auth-phase1.5" +``` + +## Mocking + +### AWS SES +Email tests use Jest mocks to avoid actual AWS SES calls: +```javascript +jest.mock('@aws-sdk/client-ses', () => ({ + SESClient: jest.fn(() => ({ send: mockSend })), + SendEmailCommand: jest.fn((params) => params) +})); +``` + +### Global Fetch (WSDC API) +WSDC tests mock the global fetch function: +```javascript +global.fetch = jest.fn(); +``` + +### Prisma (Integration Tests) +Integration tests use real Prisma client but clean up test data: +```javascript +beforeAll(async () => { + await prisma.user.deleteMany({}); +}); +``` + +## Test Structure + +Each test file follows this structure: + +1. **Setup** - Mock dependencies, configure environment +2. **BeforeEach** - Reset mocks, prepare test data +3. **Test Suites** - Grouped by functionality +4. **AfterEach** - Clean up test data +5. **AfterAll** - Disconnect from database + +## Writing New Tests + +When adding new features to Phase 1.5: + +1. **Create test file** in appropriate directory +2. **Mock external dependencies** (AWS, APIs, database) +3. **Write descriptive test names** ("should verify email with valid token") +4. **Test happy path first**, then edge cases +5. **Test error handling** and validation +6. **Aim for 100% coverage** of new code + +## Common Test Patterns + +### Testing Controller Endpoints +```javascript +const response = await request(app) + .get('/api/wsdc/lookup?id=26997') + .expect(200); + +expect(response.body.success).toBe(true); +``` + +### Testing Async Functions +```javascript +it('should send email successfully', async () => { + const result = await sendEmail({ /* params */ }); + expect(result.success).toBe(true); +}); +``` + +### Testing Error Cases +```javascript +it('should handle invalid input', async () => { + const response = await request(app) + .get('/api/wsdc/lookup?id=invalid') + .expect(400); + + expect(response.body.error).toBeDefined(); +}); +``` + +## Continuous Integration + +Tests should be run: +- ✅ Before committing code +- ✅ In CI/CD pipeline +- ✅ Before merging Pull Requests +- ✅ After deploying to staging + +## Troubleshooting + +### "Can't reach database server" +- **Unit tests**: Use `--testPathPattern` to run only unit tests +- **Integration tests**: Ensure Docker containers are running + +### "Module not found: '@aws-sdk/client-ses'" +```bash +npm install +``` + +### Tests timeout +- Increase timeout in jest.config.js or use `--testTimeout=10000` +- Check if database is responding slowly + +## Best Practices + +1. ✅ **Mock external services** (AWS, APIs) in unit tests +2. ✅ **Clean up test data** after each test +3. ✅ **Use descriptive test names** that explain what's being tested +4. ✅ **Test one thing per test** case +5. ✅ **Don't rely on test execution order** +6. ✅ **Test error cases** as thoroughly as happy paths +7. ✅ **Keep tests fast** - unit tests should run in seconds + +## Coverage Goals + +- **New Features**: 100% coverage +- **Utils**: 100% coverage +- **Controllers**: >80% coverage +- **Overall**: >80% coverage + +## Next Steps + +- [ ] Add E2E tests for full user flows +- [ ] Add performance tests for API endpoints +- [ ] Add security tests for authentication +- [ ] Set up CI/CD test automation + +--- + +**Last Updated:** 2025-11-13 +**Phase:** 1.5 (Email Verification & WSDC Integration) +**Test Status:** ✅ All Passing (65/65 tests) diff --git a/backend/src/__tests__/auth-phase1.5.test.js b/backend/src/__tests__/auth-phase1.5.test.js new file mode 100644 index 0000000..e150414 --- /dev/null +++ b/backend/src/__tests__/auth-phase1.5.test.js @@ -0,0 +1,512 @@ +/** + * Authentication API Tests - Phase 1.5 + * Tests for email verification and password reset functionality + */ + +const request = require('supertest'); +const app = require('../app'); +const { prisma } = require('../utils/db'); +const { hashPassword, generateVerificationToken, generateVerificationCode } = require('../utils/auth'); + +// Mock email service +jest.mock('../utils/email', () => ({ + sendVerificationEmail: jest.fn().mockResolvedValue({ success: true }), + sendPasswordResetEmail: jest.fn().mockResolvedValue({ success: true }), + sendWelcomeEmail: jest.fn().mockResolvedValue({ success: true }) +})); + +const emailService = require('../utils/email'); + +// Clean up database before and after tests +beforeAll(async () => { + await prisma.user.deleteMany({}); +}); + +afterAll(async () => { + await prisma.user.deleteMany({}); + await prisma.$disconnect(); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('Authentication API Tests - Phase 1.5', () => { + describe('POST /api/auth/register with WSDC data', () => { + it('should register user with WSDC data', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ + username: 'wsdcuser', + email: 'wsdc@example.com', + password: 'password123', + firstName: 'John', + lastName: 'Doe', + wsdcId: '26997' + }) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data.user.firstName).toBe('John'); + expect(response.body.data.user.lastName).toBe('Doe'); + expect(response.body.data.user.wsdcId).toBe('26997'); + expect(response.body.data.user.emailVerified).toBe(false); + }); + + it('should send verification email on registration', async () => { + await request(app) + .post('/api/auth/register') + .send({ + username: 'emailtest', + email: 'emailtest@example.com', + password: 'password123', + firstName: 'Alice', + lastName: 'Smith' + }) + .expect(201); + + expect(emailService.sendVerificationEmail).toHaveBeenCalledTimes(1); + expect(emailService.sendVerificationEmail).toHaveBeenCalledWith( + 'emailtest@example.com', + 'Alice', + expect.any(String), // verification token + expect.any(String) // verification code + ); + }); + + it('should reject duplicate WSDC ID', async () => { + await request(app) + .post('/api/auth/register') + .send({ + username: 'user1', + email: 'user1@example.com', + password: 'password123', + wsdcId: '12345' + }); + + const response = await request(app) + .post('/api/auth/register') + .send({ + username: 'user2', + email: 'user2@example.com', + password: 'password123', + wsdcId: '12345' // Same WSDC ID + }) + .expect(400); + + expect(response.body.error).toContain('WSDC ID'); + }); + + it('should continue registration even if email send fails', async () => { + emailService.sendVerificationEmail.mockRejectedValueOnce(new Error('Email failed')); + + const response = await request(app) + .post('/api/auth/register') + .send({ + username: 'emailfail', + email: 'emailfail@example.com', + password: 'password123' + }) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data.user).toBeDefined(); + }); + }); + + describe('GET /api/auth/verify-email', () => { + let testUser; + let verificationToken; + + beforeEach(async () => { + verificationToken = generateVerificationToken(); + const verificationCode = generateVerificationCode(); + const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h from now + + testUser = await prisma.user.create({ + data: { + username: 'verifytest', + email: 'verify@example.com', + passwordHash: await hashPassword('password123'), + verificationToken, + verificationCode, + verificationTokenExpiry: expiry, + emailVerified: false + } + }); + }); + + afterEach(async () => { + await prisma.user.deleteMany({ where: { email: 'verify@example.com' } }); + }); + + it('should verify email with valid token', async () => { + const response = await request(app) + .get(`/api/auth/verify-email?token=${verificationToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.message).toContain('verified successfully'); + + // Check database + const user = await prisma.user.findUnique({ where: { id: testUser.id } }); + expect(user.emailVerified).toBe(true); + expect(user.verificationToken).toBeNull(); + expect(user.verificationCode).toBeNull(); + + // Should send welcome email + expect(emailService.sendWelcomeEmail).toHaveBeenCalledTimes(1); + }); + + it('should return 400 if token is missing', async () => { + const response = await request(app) + .get('/api/auth/verify-email') + .expect(400); + + expect(response.body.error).toContain('token'); + }); + + it('should return 404 for invalid token', async () => { + const response = await request(app) + .get('/api/auth/verify-email?token=invalidtoken123') + .expect(404); + + expect(response.body.error).toContain('Invalid or expired'); + }); + + it('should return 400 for expired token', async () => { + const expiredToken = generateVerificationToken(); + const pastExpiry = new Date(Date.now() - 1000); // 1 second ago + + await prisma.user.create({ + data: { + username: 'expireduser', + email: 'expired@example.com', + passwordHash: await hashPassword('password123'), + verificationToken: expiredToken, + verificationCode: '123456', + verificationTokenExpiry: pastExpiry, + emailVerified: false + } + }); + + const response = await request(app) + .get(`/api/auth/verify-email?token=${expiredToken}`) + .expect(400); + + expect(response.body.error).toContain('expired'); + + await prisma.user.deleteMany({ where: { email: 'expired@example.com' } }); + }); + + it('should handle already verified email', async () => { + await prisma.user.update({ + where: { id: testUser.id }, + data: { emailVerified: true } + }); + + const response = await request(app) + .get(`/api/auth/verify-email?token=${verificationToken}`) + .expect(200); + + expect(response.body.message).toContain('already verified'); + }); + }); + + describe('POST /api/auth/verify-code', () => { + let testUser; + let verificationCode; + + beforeEach(async () => { + verificationCode = generateVerificationCode(); + const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); + + testUser = await prisma.user.create({ + data: { + username: 'codetest', + email: 'code@example.com', + passwordHash: await hashPassword('password123'), + verificationToken: generateVerificationToken(), + verificationCode, + verificationTokenExpiry: expiry, + emailVerified: false + } + }); + }); + + afterEach(async () => { + await prisma.user.deleteMany({ where: { email: 'code@example.com' } }); + }); + + it('should verify email with valid code', async () => { + const response = await request(app) + .post('/api/auth/verify-code') + .send({ + email: 'code@example.com', + code: verificationCode + }) + .expect(200); + + expect(response.body.success).toBe(true); + + // Check database + const user = await prisma.user.findUnique({ where: { id: testUser.id } }); + expect(user.emailVerified).toBe(true); + expect(emailService.sendWelcomeEmail).toHaveBeenCalled(); + }); + + it('should return 400 for invalid code', async () => { + const response = await request(app) + .post('/api/auth/verify-code') + .send({ + email: 'code@example.com', + code: '999999' + }) + .expect(400); + + expect(response.body.error).toContain('Invalid'); + }); + + it('should require both email and code', async () => { + const response1 = await request(app) + .post('/api/auth/verify-code') + .send({ email: 'code@example.com' }) + .expect(400); + + const response2 = await request(app) + .post('/api/auth/verify-code') + .send({ code: '123456' }) + .expect(400); + + expect(response1.body.error).toBeDefined(); + expect(response2.body.error).toBeDefined(); + }); + }); + + describe('POST /api/auth/resend-verification', () => { + let testUser; + + beforeEach(async () => { + testUser = await prisma.user.create({ + data: { + username: 'resendtest', + email: 'resend@example.com', + passwordHash: await hashPassword('password123'), + emailVerified: false + } + }); + }); + + afterEach(async () => { + await prisma.user.deleteMany({ where: { email: 'resend@example.com' } }); + }); + + it('should resend verification email', async () => { + const response = await request(app) + .post('/api/auth/resend-verification') + .send({ email: 'resend@example.com' }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(emailService.sendVerificationEmail).toHaveBeenCalled(); + + // Check that new tokens were generated + const user = await prisma.user.findUnique({ where: { id: testUser.id } }); + expect(user.verificationToken).toBeDefined(); + expect(user.verificationCode).toBeDefined(); + }); + + it('should return 400 for already verified email', async () => { + await prisma.user.update({ + where: { id: testUser.id }, + data: { emailVerified: true } + }); + + const response = await request(app) + .post('/api/auth/resend-verification') + .send({ email: 'resend@example.com' }) + .expect(400); + + expect(response.body.error).toContain('already verified'); + }); + + it('should return 404 for non-existent user', async () => { + const response = await request(app) + .post('/api/auth/resend-verification') + .send({ email: 'nonexistent@example.com' }) + .expect(404); + + expect(response.body.error).toContain('not found'); + }); + }); + + describe('POST /api/auth/request-password-reset', () => { + let testUser; + + beforeEach(async () => { + testUser = await prisma.user.create({ + data: { + username: 'resettest', + email: 'reset@example.com', + passwordHash: await hashPassword('password123'), + firstName: 'Reset', + emailVerified: true + } + }); + }); + + afterEach(async () => { + await prisma.user.deleteMany({ where: { email: 'reset@example.com' } }); + }); + + it('should send reset email for existing user', async () => { + const response = await request(app) + .post('/api/auth/request-password-reset') + .send({ email: 'reset@example.com' }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(emailService.sendPasswordResetEmail).toHaveBeenCalledWith( + 'reset@example.com', + 'Reset', + expect.any(String) + ); + + // Check database has reset token + const user = await prisma.user.findUnique({ where: { id: testUser.id } }); + expect(user.resetToken).toBeDefined(); + expect(user.resetTokenExpiry).toBeDefined(); + }); + + it('should return success even for non-existent user (security)', async () => { + const response = await request(app) + .post('/api/auth/request-password-reset') + .send({ email: 'nonexistent@example.com' }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(emailService.sendPasswordResetEmail).not.toHaveBeenCalled(); + }); + + it('should require email', async () => { + const response = await request(app) + .post('/api/auth/request-password-reset') + .send({}) + .expect(400); + + expect(response.body.error).toContain('Email'); + }); + }); + + describe('POST /api/auth/reset-password', () => { + let testUser; + let resetToken; + + beforeEach(async () => { + resetToken = generateVerificationToken(); + const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1h from now + + testUser = await prisma.user.create({ + data: { + username: 'newpasstest', + email: 'newpass@example.com', + passwordHash: await hashPassword('oldpassword123'), + resetToken, + resetTokenExpiry: expiry, + emailVerified: true + } + }); + }); + + afterEach(async () => { + await prisma.user.deleteMany({ where: { email: 'newpass@example.com' } }); + }); + + it('should reset password with valid token', async () => { + const response = await request(app) + .post('/api/auth/reset-password') + .send({ + token: resetToken, + newPassword: 'newpassword123' + }) + .expect(200); + + expect(response.body.success).toBe(true); + + // Check database + const user = await prisma.user.findUnique({ where: { id: testUser.id } }); + expect(user.resetToken).toBeNull(); + expect(user.resetTokenExpiry).toBeNull(); + + // Password should be changed (test by comparing hashes are different) + expect(user.passwordHash).not.toBe(testUser.passwordHash); + }); + + it('should reject short password', async () => { + const response = await request(app) + .post('/api/auth/reset-password') + .send({ + token: resetToken, + newPassword: 'short' + }) + .expect(400); + + expect(response.body.error).toContain('8 characters'); + }); + + it('should reject invalid token', async () => { + const response = await request(app) + .post('/api/auth/reset-password') + .send({ + token: 'invalidtoken', + newPassword: 'newpassword123' + }) + .expect(400); + + expect(response.body.error).toContain('Invalid or expired'); + }); + + it('should reject expired token', async () => { + const expiredToken = generateVerificationToken(); + const pastExpiry = new Date(Date.now() - 1000); + + await prisma.user.create({ + data: { + username: 'expiredreset', + email: 'expiredreset@example.com', + passwordHash: await hashPassword('password123'), + resetToken: expiredToken, + resetTokenExpiry: pastExpiry, + emailVerified: true + } + }); + + const response = await request(app) + .post('/api/auth/reset-password') + .send({ + token: expiredToken, + newPassword: 'newpassword123' + }) + .expect(400); + + expect(response.body.error).toContain('expired'); + + await prisma.user.deleteMany({ where: { email: 'expiredreset@example.com' } }); + }); + + it('should require both token and new password', async () => { + const response1 = await request(app) + .post('/api/auth/reset-password') + .send({ token: resetToken }) + .expect(400); + + const response2 = await request(app) + .post('/api/auth/reset-password') + .send({ newPassword: 'newpassword123' }) + .expect(400); + + expect(response1.body.error).toBeDefined(); + expect(response2.body.error).toBeDefined(); + }); + }); +}); diff --git a/backend/src/__tests__/middleware/auth.test.js b/backend/src/__tests__/middleware/auth.test.js new file mode 100644 index 0000000..1e0731a --- /dev/null +++ b/backend/src/__tests__/middleware/auth.test.js @@ -0,0 +1,188 @@ +/** + * Auth Middleware Tests (Phase 1.5) + * Tests for authentication and email verification middleware + */ + +const { requireEmailVerification } = require('../../middleware/auth'); + +describe('Auth Middleware Tests (Phase 1.5)', () => { + let req, res, next; + + beforeEach(() => { + req = { + user: null + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + next = jest.fn(); + }); + + describe('requireEmailVerification', () => { + it('should pass through if email is verified', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: true + }; + + await requireEmailVerification(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should return 401 if user is not attached to request', async () => { + req.user = null; + + await requireEmailVerification(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: 'Unauthorized' + }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 403 if email is not verified', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: false + }; + + await requireEmailVerification(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: 'Email Not Verified', + requiresVerification: true + }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('should include helpful message for unverified email', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: false + }; + + await requireEmailVerification(req, res, next); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('verify your email') + }) + ); + }); + + it('should handle undefined emailVerified as false', async () => { + req.user = { + id: 1, + email: 'user@example.com' + // emailVerified is undefined + }; + + await requireEmailVerification(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Force an error by making req.user a getter that throws + Object.defineProperty(req, 'user', { + get: () => { + throw new Error('Test error'); + } + }); + + await requireEmailVerification(req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: 'Internal Server Error' + }) + ); + + consoleSpy.mockRestore(); + }); + + it('should not call next if verification fails', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: false + }; + + await requireEmailVerification(req, res, next); + + expect(next).not.toHaveBeenCalled(); + }); + + it('should work with verified users multiple times', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: true + }; + + await requireEmailVerification(req, res, next); + await requireEmailVerification(req, res, next); + await requireEmailVerification(req, res, next); + + expect(next).toHaveBeenCalledTimes(3); + }); + + it('should handle boolean true emailVerified', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: true + }; + + await requireEmailVerification(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should handle boolean false emailVerified', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: false + }; + + await requireEmailVerification(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it('should include requiresVerification flag in response', async () => { + req.user = { + id: 1, + email: 'user@example.com', + emailVerified: false + }; + + await requireEmailVerification(req, res, next); + + const jsonCall = res.json.mock.calls[0][0]; + expect(jsonCall.requiresVerification).toBe(true); + }); + }); +}); diff --git a/backend/src/__tests__/utils/auth.test.js b/backend/src/__tests__/utils/auth.test.js index f8dbdb4..266e208 100644 --- a/backend/src/__tests__/utils/auth.test.js +++ b/backend/src/__tests__/utils/auth.test.js @@ -1,4 +1,12 @@ -const { hashPassword, comparePassword, generateToken, verifyToken } = require('../../utils/auth'); +const { + hashPassword, + comparePassword, + generateToken, + verifyToken, + generateVerificationToken, + generateVerificationCode, + getTokenExpiry +} = require('../../utils/auth'); // Set up test environment variables beforeAll(() => { @@ -96,4 +104,99 @@ describe('Auth Utils Tests', () => { expect(Math.abs(decoded.exp - expectedExpiration)).toBeLessThan(60); }); }); + + describe('generateVerificationToken (Phase 1.5)', () => { + it('should generate a random verification token', () => { + const token = generateVerificationToken(); + + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + expect(token.length).toBe(64); // 32 bytes * 2 (hex) + }); + + it('should generate unique tokens', () => { + const token1 = generateVerificationToken(); + const token2 = generateVerificationToken(); + const token3 = generateVerificationToken(); + + expect(token1).not.toBe(token2); + expect(token2).not.toBe(token3); + expect(token1).not.toBe(token3); + }); + + it('should generate URL-safe tokens (hex)', () => { + const token = generateVerificationToken(); + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + }); + + describe('generateVerificationCode (Phase 1.5)', () => { + it('should generate a 6-digit code', () => { + const code = generateVerificationCode(); + + expect(code).toBeDefined(); + expect(typeof code).toBe('string'); + expect(code.length).toBe(6); + }); + + it('should generate numeric codes', () => { + const code = generateVerificationCode(); + expect(code).toMatch(/^\d{6}$/); + }); + + it('should generate codes in valid range', () => { + const code = generateVerificationCode(); + const numCode = parseInt(code, 10); + + expect(numCode).toBeGreaterThanOrEqual(100000); + expect(numCode).toBeLessThanOrEqual(999999); + }); + + it('should generate different codes', () => { + const codes = new Set(); + for (let i = 0; i < 100; i++) { + codes.add(generateVerificationCode()); + } + // Should have at least 90 unique codes out of 100 + expect(codes.size).toBeGreaterThan(90); + }); + }); + + describe('getTokenExpiry (Phase 1.5)', () => { + it('should generate expiry date with default 24 hours', () => { + const now = new Date(); + const expiry = getTokenExpiry(); + + expect(expiry).toBeInstanceOf(Date); + expect(expiry.getTime()).toBeGreaterThan(now.getTime()); + + // Should be approximately 24 hours from now (allowing 1 second tolerance) + const expectedTime = now.getTime() + (24 * 60 * 60 * 1000); + expect(Math.abs(expiry.getTime() - expectedTime)).toBeLessThan(1000); + }); + + it('should generate expiry date with custom hours', () => { + const now = new Date(); + const expiry = getTokenExpiry(1); // 1 hour + + expect(expiry).toBeInstanceOf(Date); + expect(expiry.getTime()).toBeGreaterThan(now.getTime()); + + // Should be approximately 1 hour from now + const expectedTime = now.getTime() + (1 * 60 * 60 * 1000); + expect(Math.abs(expiry.getTime() - expectedTime)).toBeLessThan(1000); + }); + + it('should work with various hour values', () => { + const now = new Date(); + + const expiry12h = getTokenExpiry(12); + const expiry48h = getTokenExpiry(48); + const expiry1h = getTokenExpiry(1); + + expect(expiry1h.getTime() - now.getTime()).toBeCloseTo(1 * 60 * 60 * 1000, -3); + expect(expiry12h.getTime() - now.getTime()).toBeCloseTo(12 * 60 * 60 * 1000, -3); + expect(expiry48h.getTime() - now.getTime()).toBeCloseTo(48 * 60 * 60 * 1000, -3); + }); + }); }); diff --git a/backend/src/__tests__/utils/email.test.js b/backend/src/__tests__/utils/email.test.js new file mode 100644 index 0000000..1f1d8e6 --- /dev/null +++ b/backend/src/__tests__/utils/email.test.js @@ -0,0 +1,345 @@ +/** + * Email Service Tests (Phase 1.5) + * Tests for AWS SES email functionality + */ + +// Mock AWS SES before requiring the module +const mockSend = jest.fn(); +jest.mock('@aws-sdk/client-ses', () => ({ + SESClient: jest.fn(() => ({ + send: mockSend + })), + SendEmailCommand: jest.fn((params) => params) +})); + +const { + sendEmail, + sendVerificationEmail, + sendPasswordResetEmail, + sendWelcomeEmail +} = require('../../utils/email'); + +// Set up test environment variables +beforeAll(() => { + process.env.AWS_REGION = 'us-east-1'; + process.env.AWS_ACCESS_KEY_ID = 'test-key'; + process.env.AWS_SECRET_ACCESS_KEY = 'test-secret'; + process.env.SES_FROM_EMAIL = 'test@example.com'; + process.env.SES_FROM_NAME = 'Test App'; + process.env.FRONTEND_URL = 'http://localhost:8080'; +}); + +beforeEach(() => { + mockSend.mockClear(); + // Default successful response + mockSend.mockResolvedValue({ MessageId: 'test-message-id' }); +}); + +describe('Email Service Tests (Phase 1.5)', () => { + describe('sendEmail', () => { + it('should send email successfully', async () => { + const result = await sendEmail({ + to: 'user@example.com', + subject: 'Test Subject', + htmlBody: '
Test HTML
', + textBody: 'Test Text' + }); + + expect(result.success).toBe(true); + expect(result.messageId).toBe('test-message-id'); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('should use correct sender information', async () => { + await sendEmail({ + to: 'user@example.com', + subject: 'Test', + htmlBody: 'Test
', + textBody: 'Test' + }); + + const sendCommand = mockSend.mock.calls[0][0]; + expect(sendCommand.Source).toBe('Test AppTest
', + textBody: 'Test' + }); + + const sendCommand = mockSend.mock.calls[0][0]; + expect(sendCommand.Destination.ToAddresses).toEqual(['recipient@example.com']); + }); + + it('should handle email send failure', async () => { + mockSend.mockRejectedValue(new Error('SES Error')); + + await expect(sendEmail({ + to: 'user@example.com', + subject: 'Test', + htmlBody: 'Test
', + textBody: 'Test' + })).rejects.toThrow('Failed to send email'); + }); + + it('should include both HTML and text bodies', async () => { + const htmlBody = 'Test
', + textBody: 'Test' + })).rejects.toThrow(); + }); + + it('should log errors', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + mockSend.mockRejectedValue(new Error('Test error')); + + try { + await sendEmail({ + to: 'user@example.com', + subject: 'Test', + htmlBody: 'Test
', + textBody: 'Test' + }); + } catch (error) { + // Expected to throw + } + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/backend/src/__tests__/wsdc.test.js b/backend/src/__tests__/wsdc.test.js new file mode 100644 index 0000000..c6fda4e --- /dev/null +++ b/backend/src/__tests__/wsdc.test.js @@ -0,0 +1,212 @@ +/** + * WSDC Controller Tests (Phase 1.5) + * Tests for WSDC API proxy functionality + */ + +const request = require('supertest'); +const app = require('../app'); + +// Mock fetch globally +global.fetch = jest.fn(); + +describe('WSDC Controller Tests (Phase 1.5)', () => { + beforeEach(() => { + fetch.mockClear(); + }); + + describe('GET /api/wsdc/lookup', () => { + it('should lookup dancer by WSDC ID successfully', async () => { + const mockDancerData = { + dancer_wsdcid: 26997, + dancer_first: 'Radoslaw', + dancer_last: 'Gierwialo', + recent_year: 2025 + }; + + fetch.mockResolvedValue({ + ok: true, + json: async () => mockDancerData + }); + + const response = await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.dancer).toMatchObject({ + wsdcId: 26997, + firstName: 'Radoslaw', + lastName: 'Gierwialo' + }); + }); + + it('should return 400 if WSDC ID is missing', async () => { + const response = await request(app) + .get('/api/wsdc/lookup'); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Bad Request'); + expect(response.body.message).toContain('WSDC ID is required'); + }); + + it('should return 400 for invalid WSDC ID format', async () => { + const response = await request(app) + .get('/api/wsdc/lookup?id=abc123'); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Bad Request'); + expect(response.body.message).toContain('Invalid WSDC ID format'); + }); + + it('should return 400 for WSDC ID too long', async () => { + const response = await request(app) + .get('/api/wsdc/lookup?id=12345678901'); // 11 digits + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Bad Request'); + }); + + it('should return 404 if dancer not found', async () => { + fetch.mockResolvedValue({ + ok: true, + json: async () => ({}) // Empty response + }); + + const response = await request(app) + .get('/api/wsdc/lookup?id=99999'); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Not Found'); + expect(response.body.message).toContain('not found'); + }); + + it('should return 502 if WSDC API fails', async () => { + fetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error' + }); + + const response = await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(response.status).toBe(502); + expect(response.body.error).toBe('Bad Gateway'); + }); + + it('should handle network errors', async () => { + fetch.mockRejectedValue(new Error('Network error')); + + const response = await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(response.status).toBe(500); + expect(response.body.error).toBe('Internal Server Error'); + }); + + it('should call WSDC API with correct URL', async () => { + fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + dancer_wsdcid: 26997, + dancer_first: 'Test', + dancer_last: 'User' + }) + }); + + await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(fetch).toHaveBeenCalledWith( + 'https://points.worldsdc.com/lookup2020/find?q=26997' + ); + }); + + it('should validate numeric WSDC IDs only', async () => { + const invalidIds = ['abc', '123abc', 'test', '!@#$']; + + for (const id of invalidIds) { + const response = await request(app) + .get(`/api/wsdc/lookup?id=${id}`); + + expect(response.status).toBe(400); + } + }); + + it('should accept valid numeric WSDC IDs', async () => { + fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + dancer_wsdcid: 123, + dancer_first: 'Test', + dancer_last: 'User' + }) + }); + + const validIds = ['1', '123', '12345', '1234567890']; + + for (const id of validIds) { + const response = await request(app) + .get(`/api/wsdc/lookup?id=${id}`); + + expect(response.status).toBe(200); + } + }); + + it('should include optional fields if available', async () => { + const mockDancerData = { + dancer_wsdcid: 26997, + dancer_first: 'Radoslaw', + dancer_last: 'Gierwialo', + recent_year: 2025, + dominate_data: { + short_dominate_role: 'Leader' + } + }; + + fetch.mockResolvedValue({ + ok: true, + json: async () => mockDancerData + }); + + const response = await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(response.status).toBe(200); + expect(response.body.dancer.recentYear).toBe(2025); + expect(response.body.dancer.dominateRole).toBe('Leader'); + }); + + it('should handle missing optional fields gracefully', async () => { + const mockDancerData = { + dancer_wsdcid: 26997, + dancer_first: 'Test', + dancer_last: 'User' + // No recent_year or dominate_data + }; + + fetch.mockResolvedValue({ + ok: true, + json: async () => mockDancerData + }); + + const response = await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(response.status).toBe(200); + expect(response.body.dancer.dominateRole).toBeNull(); + }); + + it('should log errors for debugging', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + fetch.mockRejectedValue(new Error('Test error')); + + await request(app) + .get('/api/wsdc/lookup?id=26997'); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/backend/src/app.js b/backend/src/app.js index 4a714df..43081e9 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -31,6 +31,7 @@ app.get('/api/health', (req, res) => { app.use('/api/auth', require('./routes/auth')); app.use('/api/users', require('./routes/users')); app.use('/api/events', require('./routes/events')); +app.use('/api/wsdc', require('./routes/wsdc')); // app.use('/api/matches', require('./routes/matches')); // app.use('/api/ratings', require('./routes/ratings')); diff --git a/backend/src/controllers/auth.js b/backend/src/controllers/auth.js index 0f850a1..6d26d94 100644 --- a/backend/src/controllers/auth.js +++ b/backend/src/controllers/auth.js @@ -1,10 +1,18 @@ const { prisma } = require('../utils/db'); -const { hashPassword, comparePassword, generateToken } = require('../utils/auth'); +const { + hashPassword, + comparePassword, + generateToken, + generateVerificationToken, + generateVerificationCode, + getTokenExpiry +} = require('../utils/auth'); +const { sendVerificationEmail, sendWelcomeEmail, sendPasswordResetEmail } = require('../utils/email'); -// Register new user +// Register new user (Phase 1.5 - with WSDC support and email verification) async function register(req, res, next) { try { - const { username, email, password } = req.body; + const { username, email, password, firstName, lastName, wsdcId } = req.body; // Check if user already exists const existingUser = await prisma.user.findFirst({ @@ -12,6 +20,7 @@ async function register(req, res, next) { OR: [ { email }, { username }, + ...(wsdcId ? [{ wsdcId }] : []), ], }, }); @@ -23,38 +32,80 @@ async function register(req, res, next) { error: 'Email already registered', }); } - return res.status(400).json({ - success: false, - error: 'Username already taken', - }); + if (existingUser.username === username) { + return res.status(400).json({ + success: false, + error: 'Username already taken', + }); + } + if (wsdcId && existingUser.wsdcId === wsdcId) { + return res.status(400).json({ + success: false, + error: 'WSDC ID already registered', + }); + } } // Hash password const passwordHash = await hashPassword(password); + // Generate verification token and code + const verificationToken = generateVerificationToken(); + const verificationCode = generateVerificationCode(); + const verificationTokenExpiry = getTokenExpiry(24); // 24 hours + + // Create display name for avatar + const displayName = firstName && lastName + ? `${firstName} ${lastName}` + : username; + // Create user const user = await prisma.user.create({ data: { username, email, passwordHash, - avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(username)}&background=6366f1&color=fff`, + firstName, + lastName, + wsdcId, + verificationToken, + verificationCode, + verificationTokenExpiry, + emailVerified: false, + avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(displayName)}&background=6366f1&color=fff`, }, select: { id: true, username: true, email: true, + firstName: true, + lastName: true, + wsdcId: true, + emailVerified: true, avatar: true, createdAt: true, }, }); - // Generate token + // Send verification email + try { + await sendVerificationEmail( + user.email, + user.firstName || user.username, + verificationToken, + verificationCode + ); + } catch (emailError) { + console.error('Failed to send verification email:', emailError); + // Continue even if email fails - user can request resend + } + + // Generate JWT token const token = generateToken({ userId: user.id }); res.status(201).json({ success: true, - message: 'User registered successfully', + message: 'User registered successfully. Please check your email to verify your account.', data: { user, token, @@ -111,7 +162,337 @@ async function login(req, res, next) { } } +// Verify email by token (link in email) +async function verifyEmailByToken(req, res, next) { + try { + const { token } = req.query; + + if (!token) { + return res.status(400).json({ + success: false, + error: 'Verification token is required', + }); + } + + // Find user by verification token + const user = await prisma.user.findUnique({ + where: { verificationToken: token }, + }); + + if (!user) { + return res.status(404).json({ + success: false, + error: 'Invalid or expired verification token', + }); + } + + // Check if already verified + if (user.emailVerified) { + return res.status(200).json({ + success: true, + message: 'Email already verified', + }); + } + + // Check if token expired + if (user.verificationTokenExpiry && new Date() > user.verificationTokenExpiry) { + return res.status(400).json({ + success: false, + error: 'Verification token has expired. Please request a new one.', + }); + } + + // Update user - mark as verified and clear tokens + await prisma.user.update({ + where: { id: user.id }, + data: { + emailVerified: true, + verificationToken: null, + verificationCode: null, + verificationTokenExpiry: null, + }, + }); + + // Send welcome email + try { + await sendWelcomeEmail(user.email, user.firstName || user.username); + } catch (emailError) { + console.error('Failed to send welcome email:', emailError); + } + + res.status(200).json({ + success: true, + message: 'Email verified successfully!', + }); + } catch (error) { + next(error); + } +} + +// Verify email by code (6-digit PIN) +async function verifyEmailByCode(req, res, next) { + try { + const { code, email } = req.body; + + if (!code || !email) { + return res.status(400).json({ + success: false, + error: 'Email and verification code are required', + }); + } + + // Find user by email and code + const user = await prisma.user.findFirst({ + where: { + email, + verificationCode: code, + }, + }); + + if (!user) { + return res.status(400).json({ + success: false, + error: 'Invalid verification code', + }); + } + + // Check if already verified + if (user.emailVerified) { + return res.status(200).json({ + success: true, + message: 'Email already verified', + }); + } + + // Check if token expired + if (user.verificationTokenExpiry && new Date() > user.verificationTokenExpiry) { + return res.status(400).json({ + success: false, + error: 'Verification code has expired. Please request a new one.', + }); + } + + // Update user - mark as verified and clear tokens + await prisma.user.update({ + where: { id: user.id }, + data: { + emailVerified: true, + verificationToken: null, + verificationCode: null, + verificationTokenExpiry: null, + }, + }); + + // Send welcome email + try { + await sendWelcomeEmail(user.email, user.firstName || user.username); + } catch (emailError) { + console.error('Failed to send welcome email:', emailError); + } + + res.status(200).json({ + success: true, + message: 'Email verified successfully!', + }); + } catch (error) { + next(error); + } +} + +// Resend verification email +async function resendVerification(req, res, next) { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ + success: false, + error: 'Email is required', + }); + } + + // Find user by email + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + return res.status(404).json({ + success: false, + error: 'User not found', + }); + } + + // Check if already verified + if (user.emailVerified) { + return res.status(400).json({ + success: false, + error: 'Email is already verified', + }); + } + + // Generate new verification token and code + const verificationToken = generateVerificationToken(); + const verificationCode = generateVerificationCode(); + const verificationTokenExpiry = getTokenExpiry(24); // 24 hours + + // Update user with new tokens + await prisma.user.update({ + where: { id: user.id }, + data: { + verificationToken, + verificationCode, + verificationTokenExpiry, + }, + }); + + // Send verification email + await sendVerificationEmail( + user.email, + user.firstName || user.username, + verificationToken, + verificationCode + ); + + res.status(200).json({ + success: true, + message: 'Verification email sent successfully', + }); + } catch (error) { + next(error); + } +} + +// Request password reset (send email with reset link) +async function requestPasswordReset(req, res, next) { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ + success: false, + error: 'Email is required', + }); + } + + // Find user by email + const user = await prisma.user.findUnique({ + where: { email }, + }); + + // Always return success to prevent email enumeration + if (!user) { + return res.status(200).json({ + success: true, + message: 'If an account with that email exists, a password reset link has been sent.', + }); + } + + // Generate reset token + const resetToken = generateVerificationToken(); + const resetTokenExpiry = getTokenExpiry(1); // 1 hour expiry + + // Save reset token to database + await prisma.user.update({ + where: { id: user.id }, + data: { + resetToken, + resetTokenExpiry, + }, + }); + + // Send password reset email + try { + await sendPasswordResetEmail( + user.email, + user.firstName || user.username, + resetToken + ); + } catch (emailError) { + console.error('Failed to send password reset email:', emailError); + return res.status(500).json({ + success: false, + error: 'Failed to send password reset email', + }); + } + + res.status(200).json({ + success: true, + message: 'If an account with that email exists, a password reset link has been sent.', + }); + } catch (error) { + next(error); + } +} + +// Reset password using token +async function resetPassword(req, res, next) { + try { + const { token, newPassword } = req.body; + + if (!token || !newPassword) { + return res.status(400).json({ + success: false, + error: 'Token and new password are required', + }); + } + + // Validate password length + if (newPassword.length < 8) { + return res.status(400).json({ + success: false, + error: 'Password must be at least 8 characters long', + }); + } + + // Find user by reset token + const user = await prisma.user.findUnique({ + where: { resetToken: token }, + }); + + if (!user) { + return res.status(400).json({ + success: false, + error: 'Invalid or expired reset token', + }); + } + + // Check if token expired + if (user.resetTokenExpiry && new Date() > user.resetTokenExpiry) { + return res.status(400).json({ + success: false, + error: 'Reset token has expired. Please request a new one.', + }); + } + + // Hash new password + const passwordHash = await hashPassword(newPassword); + + // Update user password and clear reset token + await prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash, + resetToken: null, + resetTokenExpiry: null, + }, + }); + + res.status(200).json({ + success: true, + message: 'Password reset successfully', + }); + } catch (error) { + next(error); + } +} + module.exports = { register, login, + verifyEmailByToken, + verifyEmailByCode, + resendVerification, + requestPasswordReset, + resetPassword, }; diff --git a/backend/src/controllers/wsdc.js b/backend/src/controllers/wsdc.js new file mode 100644 index 0000000..ba882f6 --- /dev/null +++ b/backend/src/controllers/wsdc.js @@ -0,0 +1,76 @@ +/** + * WSDC API Controller + * Provides proxy endpoint for World Swing Dance Council (WSDC) dancer lookup + */ + +const WSDC_API_BASE = 'https://points.worldsdc.com/lookup2020/find'; + +/** + * Lookup dancer by WSDC ID + * GET /api/wsdc/lookup?id=26997 + */ +exports.lookupDancer = async (req, res) => { + try { + const { id } = req.query; + + // Validate WSDC ID + if (!id) { + return res.status(400).json({ + error: 'Bad Request', + message: 'WSDC ID is required' + }); + } + + // Validate WSDC ID format (numeric, max 10 digits) + if (!/^\d{1,10}$/.test(id)) { + return res.status(400).json({ + error: 'Bad Request', + message: 'Invalid WSDC ID format. Must be numeric.' + }); + } + + // Fetch from WSDC API + const url = `${WSDC_API_BASE}?q=${id}`; + const response = await fetch(url); + + if (!response.ok) { + console.error(`WSDC API error: ${response.status} ${response.statusText}`); + return res.status(502).json({ + error: 'Bad Gateway', + message: 'Failed to fetch data from WSDC API' + }); + } + + const data = await response.json(); + + // Check if dancer was found + if (!data || !data.dancer_wsdcid) { + return res.status(404).json({ + error: 'Not Found', + message: 'Dancer with this WSDC ID not found' + }); + } + + // Extract relevant fields + const dancerData = { + wsdcId: data.dancer_wsdcid, + firstName: data.dancer_first || '', + lastName: data.dancer_last || '', + // Optional: include competitive level info if needed + recentYear: data.recent_year, + dominateRole: data.dominate_data?.short_dominate_role || null + }; + + return res.status(200).json({ + success: true, + dancer: dancerData + }); + + } catch (error) { + console.error('Error in lookupDancer:', error); + return res.status(500).json({ + error: 'Internal Server Error', + message: 'An error occurred while looking up dancer' + }); + } +}; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 49886a7..3a1db2b 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -35,6 +35,10 @@ async function authenticate(req, res, next) { id: true, username: true, email: true, + emailVerified: true, + firstName: true, + lastName: true, + wsdcId: true, avatar: true, createdAt: true, updatedAt: true, @@ -61,4 +65,37 @@ async function authenticate(req, res, next) { } } -module.exports = { authenticate }; +// Middleware to check if email is verified (Phase 1.5) +// Use this after authenticate middleware on routes that require verified email +async function requireEmailVerification(req, res, next) { + try { + // User should be attached by authenticate middleware + if (!req.user) { + return res.status(401).json({ + success: false, + error: 'Unauthorized', + message: 'Authentication required', + }); + } + + // Check if email is verified + if (!req.user.emailVerified) { + return res.status(403).json({ + success: false, + error: 'Email Not Verified', + message: 'Please verify your email address to access this feature', + requiresVerification: true, + }); + } + + next(); + } catch (error) { + console.error('Email verification middleware error:', error); + res.status(500).json({ + success: false, + error: 'Internal Server Error', + }); + } +} + +module.exports = { authenticate, requireEmailVerification }; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 3fc41f1..d20da36 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1,5 +1,13 @@ const express = require('express'); -const { register, login } = require('../controllers/auth'); +const { + register, + login, + verifyEmailByToken, + verifyEmailByCode, + resendVerification, + requestPasswordReset, + resetPassword +} = require('../controllers/auth'); const { registerValidation, loginValidation } = require('../middleware/validators'); const router = express.Router(); @@ -10,4 +18,19 @@ router.post('/register', registerValidation, register); // POST /api/auth/login - Login user router.post('/login', loginValidation, login); +// GET /api/auth/verify-email?token=xxx - Verify email by token (link) +router.get('/verify-email', verifyEmailByToken); + +// POST /api/auth/verify-code - Verify email by code (PIN) +router.post('/verify-code', verifyEmailByCode); + +// POST /api/auth/resend-verification - Resend verification email +router.post('/resend-verification', resendVerification); + +// POST /api/auth/request-password-reset - Request password reset +router.post('/request-password-reset', requestPasswordReset); + +// POST /api/auth/reset-password - Reset password with token +router.post('/reset-password', resetPassword); + module.exports = router; diff --git a/backend/src/routes/wsdc.js b/backend/src/routes/wsdc.js new file mode 100644 index 0000000..5473a24 --- /dev/null +++ b/backend/src/routes/wsdc.js @@ -0,0 +1,16 @@ +/** + * WSDC API Routes + * Endpoints for World Swing Dance Council dancer lookup + */ + +const express = require('express'); +const router = express.Router(); +const wsdcController = require('../controllers/wsdc'); + +/** + * GET /api/wsdc/lookup?id=26997 + * Lookup dancer by WSDC ID + */ +router.get('/lookup', wsdcController.lookupDancer); + +module.exports = router; diff --git a/backend/src/utils/auth.js b/backend/src/utils/auth.js index 5703a65..2381521 100644 --- a/backend/src/utils/auth.js +++ b/backend/src/utils/auth.js @@ -1,5 +1,6 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); // Hash password with bcrypt async function hashPassword(password) { @@ -28,9 +29,28 @@ function verifyToken(token) { } } +// Generate random verification token (URL-safe) +function generateVerificationToken() { + return crypto.randomBytes(32).toString('hex'); +} + +// Generate 6-digit verification code +function generateVerificationCode() { + return Math.floor(100000 + Math.random() * 900000).toString(); +} + +// Calculate token expiry time +function getTokenExpiry(hours = 24) { + const now = new Date(); + return new Date(now.getTime() + hours * 60 * 60 * 1000); +} + module.exports = { hashPassword, comparePassword, generateToken, verifyToken, + generateVerificationToken, + generateVerificationCode, + getTokenExpiry, }; diff --git a/backend/src/utils/email.js b/backend/src/utils/email.js new file mode 100644 index 0000000..1dcda00 --- /dev/null +++ b/backend/src/utils/email.js @@ -0,0 +1,320 @@ +/** + * Email Service using AWS SES + * Handles sending emails for verification, password reset, etc. + */ + +const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses'); + +// Configure AWS SES Client +const sesClient = new SESClient({ + region: process.env.AWS_REGION || 'us-east-1', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, +}); + +/** + * Send email via AWS SES + * @param {Object} params - Email parameters + * @param {string} params.to - Recipient email address + * @param {string} params.subject - Email subject + * @param {string} params.htmlBody - HTML email body + * @param {string} params.textBody - Plain text email body (fallback) + * @returns {Promise