diff --git a/.env.test b/.env.test index 8d3d7a9..2819bd0 100644 --- a/.env.test +++ b/.env.test @@ -7,3 +7,4 @@ MONGO_URL=localhost:27017 MONGO_DB=test MONGO_USERNAME=localUser MONGO_PASSWORD=localPassword +JWT_SECRET=secret diff --git a/package-lock.json b/package-lock.json index 4e903c5..712dff7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.4.0", "license": "MIT", "dependencies": { + "bcryptjs": "^2.4.3", "body-parser": "^1.20.2", "bson": "^6.1.0", "cors": "^2.8.5", @@ -21,11 +22,13 @@ "glob": "^10.3.10", "helmet": "^7.0.0", "http-status": "^1.7.0", + "jsonwebtoken": "^9.0.2", "mongodb": "^4.0.0", "node-dependency-injection": "^2.7.3", "swagger-ui-express": "^5.0.0", "ts-node": "^10.9.1", "typescript": "^5.2.2", + "uuid": "^9.0.1", "uuid-validate": "^0.0.3", "winston": "^3.11.0", "winston-mongodb": "^5.1.1", @@ -33,6 +36,7 @@ }, "devDependencies": { "@cucumber/cucumber": "^10.0.0", + "@types/bcryptjs": "^2.4.6", "@types/chai": "^4.3.8", "@types/chance": "^1.1.4", "@types/convict": "^6.1.4", @@ -41,6 +45,7 @@ "@types/express": "^4.17.19", "@types/glob": "^8.1.0", "@types/jest": "^29.5.5", + "@types/jsonwebtoken": "^9.0.5", "@types/supertest": "^2.0.14", "@types/swagger-ui-express": "^4.1.5", "@types/uuid-validate": "^0.0.1", @@ -3021,6 +3026,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true + }, "node_modules/@types/body-parser": { "version": "1.19.3", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.3.tgz", @@ -3180,6 +3191,15 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -3960,6 +3980,11 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -4142,6 +4167,11 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5492,6 +5522,14 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8391,6 +8429,27 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", @@ -8429,6 +8488,25 @@ "extsprintf": "^1.2.0" } }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", @@ -8731,6 +8809,36 @@ "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -8749,6 +8857,11 @@ "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -11544,7 +11657,6 @@ "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -11559,7 +11671,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -11570,8 +11681,7 @@ "node_modules/semver/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/send": { "version": "0.18.0", @@ -12873,6 +12983,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/uuid-validate": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/uuid-validate/-/uuid-validate-0.0.3.tgz", @@ -15871,6 +15993,12 @@ "@babel/types": "^7.20.7" } }, + "@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true + }, "@types/body-parser": { "version": "1.19.3", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.3.tgz", @@ -16030,6 +16158,15 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -16618,6 +16755,11 @@ "tweetnacl": "^0.14.3" } }, + "bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -16752,6 +16894,11 @@ "ieee754": "^1.1.13" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -17752,6 +17899,14 @@ "safer-buffer": "^2.1.0" } }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -19905,6 +20060,23 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true }, + "jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + } + }, "jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", @@ -19936,6 +20108,25 @@ } } }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "keyv": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", @@ -20141,6 +20332,36 @@ "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==" }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -20159,6 +20380,11 @@ "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", "dev": true }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -22218,7 +22444,6 @@ "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, "requires": { "lru-cache": "^6.0.0" }, @@ -22227,7 +22452,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -22235,8 +22459,7 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } }, @@ -23205,6 +23428,11 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" }, + "uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" + }, "uuid-validate": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/uuid-validate/-/uuid-validate-0.0.3.tgz", diff --git a/package.json b/package.json index 2baec09..4ea378e 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "url": "https://github.com/vinjatovix/ts-api/issues" }, "dependencies": { + "bcryptjs": "^2.4.3", "body-parser": "^1.20.2", "bson": "^6.1.0", "cors": "^2.8.5", @@ -74,11 +75,13 @@ "glob": "^10.3.10", "helmet": "^7.0.0", "http-status": "^1.7.0", + "jsonwebtoken": "^9.0.2", "mongodb": "^4.0.0", "node-dependency-injection": "^2.7.3", "swagger-ui-express": "^5.0.0", "ts-node": "^10.9.1", "typescript": "^5.2.2", + "uuid": "^9.0.1", "uuid-validate": "^0.0.3", "winston": "^3.11.0", "winston-mongodb": "^5.1.1", @@ -86,6 +89,7 @@ }, "devDependencies": { "@cucumber/cucumber": "^10.0.0", + "@types/bcryptjs": "^2.4.6", "@types/chai": "^4.3.8", "@types/chance": "^1.1.4", "@types/convict": "^6.1.4", @@ -94,6 +98,7 @@ "@types/express": "^4.17.19", "@types/glob": "^8.1.0", "@types/jest": "^29.5.5", + "@types/jsonwebtoken": "^9.0.5", "@types/supertest": "^2.0.14", "@types/swagger-ui-express": "^4.1.5", "@types/uuid-validate": "^0.0.1", diff --git a/src/Contexts/apiApp/Auth/application/LoginUser.ts b/src/Contexts/apiApp/Auth/application/LoginUser.ts new file mode 100644 index 0000000..f5007df --- /dev/null +++ b/src/Contexts/apiApp/Auth/application/LoginUser.ts @@ -0,0 +1,45 @@ +import { EncrypterTool } from '../../../shared/plugins/EncrypterTool'; +import { Nullable } from '../../../shared/domain/Nullable'; +import { AuthError } from '../../../shared/domain/errors/AuthError'; +import { buildLogger } from '../../../shared/plugins/logger.plugin'; + +import { UserRepository } from '../domain'; + +import { LoginUserRequest } from './LoginUserRequest'; + +const logger = buildLogger('loginUser'); + +export class LoginUser { + private readonly repository: UserRepository; + private readonly encrypter: EncrypterTool; + + constructor(repository: UserRepository, encrypter: EncrypterTool) { + this.repository = repository; + this.encrypter = encrypter; + } + + async run({ email, password }: LoginUserRequest): Promise> { + const storedUser = await this.repository.search(email); + if (!storedUser) { + throw new AuthError('Invalid credentials'); + } + + const success = await this.encrypter.compare( + password, + storedUser.password.value + ); + if (!success) { + throw new AuthError('Invalid credentials'); + } + + const token = await this.encrypter.generateToken({ + id: storedUser.id.value, + email: storedUser.email.value, + username: storedUser.username.value, + roles: storedUser.roles.value + }); + + logger.info(`User <${storedUser.username.value}> logged in`); + return token; + } +} diff --git a/src/Contexts/apiApp/Auth/application/LoginUserRequest.ts b/src/Contexts/apiApp/Auth/application/LoginUserRequest.ts new file mode 100644 index 0000000..8574e0e --- /dev/null +++ b/src/Contexts/apiApp/Auth/application/LoginUserRequest.ts @@ -0,0 +1,4 @@ +export interface LoginUserRequest { + email: string; + password: string; +} diff --git a/src/Contexts/apiApp/Auth/application/RegisterUser.ts b/src/Contexts/apiApp/Auth/application/RegisterUser.ts new file mode 100644 index 0000000..74b28fd --- /dev/null +++ b/src/Contexts/apiApp/Auth/application/RegisterUser.ts @@ -0,0 +1,42 @@ +import { EncrypterTool } from '../../../shared/plugins/EncrypterTool'; +import { InvalidArgumentError } from '../../../shared/domain/errors/InvalidArgumentError'; +import { StringValueObject } from '../../../shared/domain/value-object/StringValueObject'; +import { Uuid } from '../../../shared/domain/value-object/Uuid'; +import { buildLogger } from '../../../shared/plugins/logger.plugin'; + +import { User, Email, UserRepository, UserRoles, Username } from '../domain'; + +import { RegisterUserRequest } from './RegisterUserRequest'; + +const logger = buildLogger('registerUser'); + +export class RegisterUser { + private readonly repository: UserRepository; + private readonly encrypter: EncrypterTool; + + constructor(repository: UserRepository, encrypter: EncrypterTool) { + this.repository = repository; + this.encrypter = encrypter; + } + + async run({ password, username, email }: RegisterUserRequest): Promise { + const storedUser = await this.repository.search(email); + if (storedUser) { + throw new InvalidArgumentError(`User <${email}> already exists`); + } + + const encryptedPassword = this.encrypter.hash(password); + + const user = new User({ + id: Uuid.random(), + email: new Email(email), + username: new Username(username), + password: new StringValueObject(encryptedPassword), + emailValidated: false, + roles: new UserRoles(['user']) + }); + + await this.repository.save(user); + logger.info(`User <${user.username.value}> registered`); + } +} diff --git a/src/Contexts/apiApp/Auth/application/RegisterUserRequest.ts b/src/Contexts/apiApp/Auth/application/RegisterUserRequest.ts new file mode 100644 index 0000000..211ef82 --- /dev/null +++ b/src/Contexts/apiApp/Auth/application/RegisterUserRequest.ts @@ -0,0 +1,5 @@ +import { LoginUserRequest } from './LoginUserRequest'; + +export interface RegisterUserRequest extends LoginUserRequest { + username: string; +} diff --git a/src/Contexts/apiApp/Auth/application/ValidateMail.ts b/src/Contexts/apiApp/Auth/application/ValidateMail.ts new file mode 100644 index 0000000..7c0d81a --- /dev/null +++ b/src/Contexts/apiApp/Auth/application/ValidateMail.ts @@ -0,0 +1,40 @@ +import { EncrypterTool } from '../../../shared/plugins/EncrypterTool'; +import { UserRepository } from '../domain'; +import { UserPatch } from '../domain/UserPatch'; +import { buildLogger } from '../../../shared/plugins/logger.plugin'; +import { Nullable } from '../../../shared/domain/Nullable'; +import { AuthError } from '../../../shared/domain/errors/AuthError'; + +const logger = buildLogger('validateMail'); + +export class ValidateMail { + private readonly repository: UserRepository; + private readonly encrypter: EncrypterTool; + + constructor(repository: UserRepository, encrypter: EncrypterTool) { + this.repository = repository; + this.encrypter = encrypter; + } + + async run({ token }: { token: string }): Promise> { + const validToken = await this.encrypter.verifyToken(token); + if (!validToken) { + throw new AuthError('Invalid token'); + } + const { email } = validToken as unknown as { email: string }; + + const storedUser = await this.repository.search(email); + if (!storedUser) { + throw new AuthError('Invalid token'); + } + const userToPatch = UserPatch.fromPrimitives({ + id: storedUser.id.value, + emailValidated: true + }); + + await this.repository.update(userToPatch); + logger.info(`User <${storedUser.username.value}> validated email`); + + return this.encrypter.refreshToken(token); + } +} diff --git a/src/Contexts/apiApp/Auth/application/index.ts b/src/Contexts/apiApp/Auth/application/index.ts new file mode 100644 index 0000000..2802fd2 --- /dev/null +++ b/src/Contexts/apiApp/Auth/application/index.ts @@ -0,0 +1,5 @@ +export * from './LoginUser'; +export * from './RegisterUser'; +export * from './ValidateMail'; + +export * from './LoginUserRequest'; diff --git a/src/Contexts/apiApp/Auth/domain/User.ts b/src/Contexts/apiApp/Auth/domain/User.ts new file mode 100644 index 0000000..8fd3c9e --- /dev/null +++ b/src/Contexts/apiApp/Auth/domain/User.ts @@ -0,0 +1,75 @@ +import { AggregateRoot } from '../../../shared/domain/AggregateRoot'; +import { StringValueObject } from '../../../shared/domain/value-object/StringValueObject'; +import { Uuid } from '../../../shared/domain/value-object/Uuid'; +import { Email } from '../../../shared/domain/value-object/Email'; +import { UserRoles } from './UserRoles'; +import { Username } from './Username'; + +export class User extends AggregateRoot { + readonly id: Uuid; + readonly email: Email; + readonly username: Username; + readonly password: StringValueObject; + readonly emailValidated: boolean; + readonly roles: UserRoles; + + constructor({ + id, + email, + username, + password, + emailValidated, + roles + }: { + id: Uuid; + email: Email; + username: Username; + password: StringValueObject; + emailValidated: boolean; + roles: UserRoles; + }) { + super(); + this.id = id; + this.email = email; + this.username = username; + this.password = password; + this.emailValidated = emailValidated; + this.roles = roles; + } + + toPrimitives(): Record { + return { + id: this.id.value, + email: this.email.value, + username: this.username.value, + password: this.password.value, + emailValidated: this.emailValidated, + roles: this.roles.value + }; + } + + static fromPrimitives({ + id, + email, + username, + password, + emailValidated, + roles + }: { + id: string; + email: string; + username: string; + password: string; + emailValidated: boolean; + roles: string[]; + }): User { + return new User({ + id: new Uuid(id), + email: new Email(email), + username: new Username(username), + password: new StringValueObject(password), + emailValidated, + roles: new UserRoles(roles) + }); + } +} diff --git a/src/Contexts/apiApp/Auth/domain/UserPatch.ts b/src/Contexts/apiApp/Auth/domain/UserPatch.ts new file mode 100644 index 0000000..71ca0f1 --- /dev/null +++ b/src/Contexts/apiApp/Auth/domain/UserPatch.ts @@ -0,0 +1,57 @@ +import { AggregateRoot } from '../../../shared/domain/AggregateRoot'; +import { StringValueObject } from '../../../shared/domain/value-object/StringValueObject'; +import { Uuid } from '../../../shared/domain/value-object/Uuid'; +import { UserRoles } from './UserRoles'; + +export class UserPatch extends AggregateRoot { + readonly id: Uuid; + readonly password?: StringValueObject; + readonly emailValidated?: boolean; + readonly roles?: UserRoles; + + constructor({ + id, + password, + emailValidated, + roles + }: { + id: Uuid; + password?: StringValueObject; + emailValidated?: boolean; + roles?: UserRoles; + }) { + super(); + this.id = id; + password && (this.password = password); + emailValidated && (this.emailValidated = emailValidated); + roles && (this.roles = roles); + } + + toPrimitives() { + return { + id: this.id.value, + ...(this.password?.value && { password: this.password.value }), + ...(this.emailValidated && { emailValidated: this.emailValidated }), + ...(this.roles?.value && { roles: this.roles.value }) + }; + } + + static fromPrimitives({ + id, + password, + emailValidated, + roles + }: { + id: string; + password?: string; + emailValidated?: boolean; + roles?: string[]; + }) { + return new UserPatch({ + id: new Uuid(id), + ...(password && { password: new StringValueObject(password) }), + ...(emailValidated && { emailValidated }), + ...(roles && { roles: new UserRoles(roles) }) + }); + } +} diff --git a/src/Contexts/apiApp/Auth/domain/UserRepository.ts b/src/Contexts/apiApp/Auth/domain/UserRepository.ts new file mode 100644 index 0000000..b718c9e --- /dev/null +++ b/src/Contexts/apiApp/Auth/domain/UserRepository.ts @@ -0,0 +1,11 @@ +import { Nullable } from '../../../shared/domain/Nullable'; +import { User } from './User'; +import { UserPatch } from './UserPatch'; + +export interface UserRepository { + save(user: User): Promise; + + update(user: UserPatch): Promise; + + search(email: string): Promise>; +} diff --git a/src/Contexts/apiApp/Auth/domain/UserRoles.ts b/src/Contexts/apiApp/Auth/domain/UserRoles.ts new file mode 100644 index 0000000..d2c1628 --- /dev/null +++ b/src/Contexts/apiApp/Auth/domain/UserRoles.ts @@ -0,0 +1,21 @@ +import { InvalidArgumentError } from '../../../shared/domain/errors/InvalidArgumentError'; + +export class UserRoles { + private static validRoles = ['admin', 'user']; + readonly value: string[]; + + constructor(value: string[]) { + this.ensureRoles(value); + this.value = value; + } + + private ensureRoles(value: string[]): void { + value.forEach((role) => { + if (!UserRoles.validRoles.includes(role)) { + throw new InvalidArgumentError( + `<${this.constructor.name}> does not allow the value <${role}>` + ); + } + }); + } +} diff --git a/src/Contexts/apiApp/Auth/domain/Username.ts b/src/Contexts/apiApp/Auth/domain/Username.ts new file mode 100644 index 0000000..bf45013 --- /dev/null +++ b/src/Contexts/apiApp/Auth/domain/Username.ts @@ -0,0 +1,25 @@ +import { InvalidArgumentError } from '../../../shared/domain/errors/InvalidArgumentError'; +import { StringValueObject } from '../../../shared/domain/value-object/StringValueObject'; + +export class Username extends StringValueObject { + constructor(value: string) { + super(value); + this.ensureLength(value); + } + + private ensureLength(value: string): string { + if (value.length < 4) { + throw new InvalidArgumentError( + ' must be at least 4 characters long' + ); + } + + if (value.length > 20) { + throw new InvalidArgumentError( + ' must be less than 20 characters long' + ); + } + + return value; + } +} diff --git a/src/Contexts/apiApp/Auth/domain/index.ts b/src/Contexts/apiApp/Auth/domain/index.ts new file mode 100644 index 0000000..13b8409 --- /dev/null +++ b/src/Contexts/apiApp/Auth/domain/index.ts @@ -0,0 +1,7 @@ +export * from './User'; +export * from '../../../shared/domain/value-object/Email'; +export * from './UserRoles'; +export * from './Username'; +export * from './UserPatch'; + +export * from './UserRepository'; diff --git a/src/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.ts b/src/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.ts new file mode 100644 index 0000000..2121bdf --- /dev/null +++ b/src/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.ts @@ -0,0 +1,57 @@ +import { MongoClient } from 'mongodb'; +import { Nullable } from '../../../../shared/domain/Nullable'; +import { MongoRepository } from '../../../../shared/infrastructure/persistence/mongo/MongoRepository'; +import { User, UserRepository } from '../../domain'; +import { UserPatch } from '../../domain/UserPatch'; + +export interface AuthDocument { + _id: string; + email: string; + username: string; + password: string; + emailValidated: boolean; + roles: string[]; +} + +export class MongoAuthRepository + extends MongoRepository + implements UserRepository +{ + constructor(client: Promise) { + super(client); + this.createUniqueIndex(); + } + protected collectionName(): string { + return 'users'; + } + + async save(user: User): Promise { + return this.persist(user.id.value, user); + } + + async update(user: UserPatch): Promise { + return this.patch(user); + } + + async search(email: string): Promise> { + const collection = await this.collection(); + const document = await collection.findOne({ email }); + + return document + ? User.fromPrimitives({ + id: document._id, + email: document.email, + username: document.username, + password: document.password, + emailValidated: document.emailValidated, + roles: document.roles + }) + : null; + } + + private async createUniqueIndex(): Promise { + const collection = await this.collection(); + await collection.createIndex({ email: 1 }, { unique: true }); + await collection.createIndex({ username: 1 }, { unique: true }); + } +} diff --git a/src/Contexts/shared/domain/errors/AuthError.ts b/src/Contexts/shared/domain/errors/AuthError.ts new file mode 100644 index 0000000..c4ec6f2 --- /dev/null +++ b/src/Contexts/shared/domain/errors/AuthError.ts @@ -0,0 +1,6 @@ +export class AuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthError'; + } +} diff --git a/src/Contexts/shared/domain/errors/InvalidArgumentError.ts b/src/Contexts/shared/domain/errors/InvalidArgumentError.ts index 1b49f05..7e991fa 100644 --- a/src/Contexts/shared/domain/errors/InvalidArgumentError.ts +++ b/src/Contexts/shared/domain/errors/InvalidArgumentError.ts @@ -1 +1,6 @@ -export class InvalidArgumentError extends Error {} +export class InvalidArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidArgumentError'; + } +} diff --git a/src/Contexts/shared/domain/value-object/Email.ts b/src/Contexts/shared/domain/value-object/Email.ts new file mode 100644 index 0000000..dae5fbd --- /dev/null +++ b/src/Contexts/shared/domain/value-object/Email.ts @@ -0,0 +1,61 @@ +import { InvalidArgumentError } from '../errors/InvalidArgumentError'; + +export class Email { + private readonly emailRegex = /^[a-zA-Z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,6}$/; + private readonly minLength = 6; + private readonly maxLength = 255; + private readonly domainsBlacklist = [ + 'mailinator.com', + 'guerrillamail.com', + 'sharklasers.com' + ]; + readonly value: string; + + constructor(value: string) { + this.value = this.ensureIsValidEmail(value); + } + + private ensureLength(value: string): string { + if (value.length < this.minLength) { + throw new InvalidArgumentError( + `<${this.constructor.name}> must be at least ${this.minLength} characters long` + ); + } + + if (value.length > this.maxLength) { + throw new InvalidArgumentError( + `<${this.constructor.name}> must be less than ${this.maxLength} characters long` + ); + } + + return value; + } + + private ensureDomainsBlacklist(value: string): string { + const domain = value.split('@')[1]; + if (this.domainsBlacklist.includes(domain)) { + throw new InvalidArgumentError( + `<${this.constructor.name}> does not allow the domain <${value}>` + ); + } + return value; + } + + private ensureIsEmailAddress(value: string): string { + if (!this.emailRegex.test(value)) { + throw new InvalidArgumentError( + `<${this.constructor.name}> does not allow the value <${value}>` + ); + } + return value; + } + + private ensureIsValidEmail(value: string): string { + const trimmedValue = value.trim(); + this.ensureLength(trimmedValue); + this.ensureIsEmailAddress(trimmedValue); + this.ensureDomainsBlacklist(trimmedValue); + + return trimmedValue; + } +} diff --git a/src/Contexts/shared/domain/value-object/StringValueObject.ts b/src/Contexts/shared/domain/value-object/StringValueObject.ts index 7edb726..44f8589 100644 --- a/src/Contexts/shared/domain/value-object/StringValueObject.ts +++ b/src/Contexts/shared/domain/value-object/StringValueObject.ts @@ -1,7 +1,17 @@ +import { InvalidArgumentError } from '../errors/InvalidArgumentError'; + export class StringValueObject { readonly value: string; constructor(value: string) { - this.value = value; + this.value = this.ensureIsValidValue(value); + } + + private ensureIsValidValue(value: string): string { + if (typeof value !== 'string') { + throw new InvalidArgumentError('Invalid value'); + } + + return value.trim(); } } diff --git a/src/Contexts/shared/domain/value-object/Uuid.ts b/src/Contexts/shared/domain/value-object/Uuid.ts index f0eb222..9d4fd63 100644 --- a/src/Contexts/shared/domain/value-object/Uuid.ts +++ b/src/Contexts/shared/domain/value-object/Uuid.ts @@ -1,4 +1,6 @@ import validate from 'uuid-validate'; +import { v4 as uuidv4 } from 'uuid'; + import { InvalidArgumentError } from '../errors/InvalidArgumentError'; export class Uuid { @@ -13,6 +15,10 @@ export class Uuid { return this.value; } + static random(): Uuid { + return new Uuid(uuidv4()); + } + private ensureIsValidUuid(id: string): void { if (!validate(id)) { throw new InvalidArgumentError( diff --git a/src/Contexts/shared/plugins/CryptAdapter.ts b/src/Contexts/shared/plugins/CryptAdapter.ts new file mode 100644 index 0000000..9732c79 --- /dev/null +++ b/src/Contexts/shared/plugins/CryptAdapter.ts @@ -0,0 +1,50 @@ +import { compareSync, genSaltSync, hashSync } from 'bcryptjs'; +import { EncrypterTool } from './EncrypterTool'; +import { sign, verify } from 'jsonwebtoken'; +import { envs } from '../../../config/plugins/envs.plugin'; +import { Nullable } from '../domain/Nullable'; + +const JWT_SECRET = envs.JWT_SECRET; +const SALT_ROUNDS = 12; + +export class CryptAdapter implements EncrypterTool { + hash(password: string): string { + const salt = genSaltSync(SALT_ROUNDS); + return hashSync(password, salt); + } + + compare(password: string, hash: string): boolean { + return compareSync(password, hash); + } + + async generateToken( + payload: Record, + duration: string = '2h' + ): Promise> { + return new Promise((resolve) => { + sign(payload, JWT_SECRET, { expiresIn: duration }, (err, token) => + err ? resolve(null) : resolve(token as string) + ); + }); + } + + async verifyToken(token: string): Promise>> { + return new Promise((resolve) => { + verify(token, JWT_SECRET, (err, decoded) => + err ? resolve(null) : resolve(decoded as Record) + ); + }); + } + + async refreshToken(token: string): Promise> { + const decoded = await this.verifyToken(token); + if (!decoded) { + return null; + } + + const now = Math.floor(Date.now() / 1000); + const { exp, ...payload } = decoded as { exp: number; iat: number }; + + return exp > now ? this.generateToken(payload) : null; + } +} diff --git a/src/Contexts/shared/plugins/EncrypterTool.ts b/src/Contexts/shared/plugins/EncrypterTool.ts new file mode 100644 index 0000000..d4abf1b --- /dev/null +++ b/src/Contexts/shared/plugins/EncrypterTool.ts @@ -0,0 +1,9 @@ +import { Nullable } from '../domain/Nullable'; + +export interface EncrypterTool { + hash(value: string): string; + compare(value: string, encryptedValue: string): boolean; + generateToken(payload: Record): Promise>; + verifyToken(token: string): Promise>>; + refreshToken(token: string): Promise>; +} diff --git a/src/apps/apiApp/controllers/Auth/LoginController.ts b/src/apps/apiApp/controllers/Auth/LoginController.ts new file mode 100644 index 0000000..6bbf7b1 --- /dev/null +++ b/src/apps/apiApp/controllers/Auth/LoginController.ts @@ -0,0 +1,24 @@ +import httpStatus from 'http-status'; +import { NextFunction, Request, Response } from 'express'; +import { Controller } from '../../shared/interfaces/Controller'; +import { LoginUser } from '../../../../Contexts/apiApp/Auth/application'; + +export class LoginController implements Controller { + constructor(protected login: LoginUser) {} + + async run(req: Request, res: Response, next: NextFunction): Promise { + try { + const { email, password } = req.body; + + const token = await this.login.run({ email, password }); + + res.status(this.status()).json({ token }); + } catch (error) { + next(error); + } + } + + protected status() { + return httpStatus.OK; + } +} diff --git a/src/apps/apiApp/controllers/Auth/RegisterController.ts b/src/apps/apiApp/controllers/Auth/RegisterController.ts new file mode 100644 index 0000000..4f89477 --- /dev/null +++ b/src/apps/apiApp/controllers/Auth/RegisterController.ts @@ -0,0 +1,28 @@ +import httpStatus from 'http-status'; +import { NextFunction, Request, Response } from 'express'; +import { Controller } from '../../shared/interfaces/Controller'; +import { InvalidArgumentError } from '../../../../Contexts/shared/domain/errors/InvalidArgumentError'; +import { RegisterUser } from '../../../../Contexts/apiApp/Auth/application'; + +export class RegisterController implements Controller { + constructor(protected register: RegisterUser) {} + + async run(req: Request, res: Response, next: NextFunction): Promise { + try { + const { email, username, password, repeatPassword } = req.body; + if (password !== repeatPassword) { + throw new InvalidArgumentError('Passwords do not match'); + } + + await this.register.run({ email, password, username }); + + res.status(this.status()).send(); + } catch (error) { + next(error); + } + } + + protected status() { + return httpStatus.CREATED; + } +} diff --git a/src/apps/apiApp/controllers/Auth/ValidateMailController.ts b/src/apps/apiApp/controllers/Auth/ValidateMailController.ts new file mode 100644 index 0000000..7f63433 --- /dev/null +++ b/src/apps/apiApp/controllers/Auth/ValidateMailController.ts @@ -0,0 +1,24 @@ +import httpStatus from 'http-status'; +import { NextFunction, Request, Response } from 'express'; +import { Controller } from '../../shared/interfaces/Controller'; +import { ValidateMail } from '../../../../Contexts/apiApp/Auth/application'; + +export class ValidateMailController implements Controller { + constructor(protected validateMail: ValidateMail) {} + + async run(req: Request, res: Response, next: NextFunction): Promise { + try { + const { token } = req.params; + + const newToken = await this.validateMail.run({ token }); + + res.status(this.status()).json({ token: newToken }); + } catch (error) { + next(error); + } + } + + protected status() { + return httpStatus.OK; + } +} diff --git a/src/apps/apiApp/controllers/Auth/index.ts b/src/apps/apiApp/controllers/Auth/index.ts new file mode 100644 index 0000000..c5486fd --- /dev/null +++ b/src/apps/apiApp/controllers/Auth/index.ts @@ -0,0 +1,3 @@ +export * from './LoginController'; +export * from './RegisterController'; +export * from './ValidateMailController'; diff --git a/src/apps/apiApp/dependency-injection/Auth/application.yaml b/src/apps/apiApp/dependency-injection/Auth/application.yaml new file mode 100644 index 0000000..f4b70d9 --- /dev/null +++ b/src/apps/apiApp/dependency-injection/Auth/application.yaml @@ -0,0 +1,31 @@ +services: + apiApp.MongoConfig: + factory: + class: ../../../../Contexts/shared/infrastructure/persistence/mongo/MongoConfigFactory + method: 'createConfig' + + apiApp.MongoClient: + factory: + class: ../../../../Contexts/shared/infrastructure/persistence/mongo/MongoClientFactory + method: 'createClient' + arguments: ['apiApp', '@apiApp.MongoConfig'] + + plugin.Encrypter: + class: ../../../../Contexts/shared/plugins/CryptAdapter + arguments: [] + + apiApp.Auth.domain.AuthRepository: + class: ../../../../Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository + arguments: ['@apiApp.MongoClient'] + + apiApp.Auth.application.LoginUser: + class: ../../../../Contexts/apiApp/Auth/application/LoginUser + arguments: ['@apiApp.Auth.domain.AuthRepository', '@plugin.Encrypter'] + + apiApp.Auth.application.RegisterUser: + class: ../../../../Contexts/apiApp/Auth/application/RegisterUser + arguments: ['@apiApp.Auth.domain.AuthRepository', '@plugin.Encrypter'] + + apiApp.Auth.application.ValidateMail: + class: ../../../../Contexts/apiApp/Auth/application/ValidateMail + arguments: ['@apiApp.Auth.domain.AuthRepository', '@plugin.Encrypter'] diff --git a/src/apps/apiApp/dependency-injection/application.yaml b/src/apps/apiApp/dependency-injection/application.yaml index 9ae361e..d93cafa 100644 --- a/src/apps/apiApp/dependency-injection/application.yaml +++ b/src/apps/apiApp/dependency-injection/application.yaml @@ -1,3 +1,4 @@ imports: - { resource: ./apps/application.yaml } + - { resource: ./Auth/application.yaml } - { resource: ./Books/application.yaml } diff --git a/src/apps/apiApp/dependency-injection/apps/application.yaml b/src/apps/apiApp/dependency-injection/apps/application.yaml index 5b95356..84bd665 100644 --- a/src/apps/apiApp/dependency-injection/apps/application.yaml +++ b/src/apps/apiApp/dependency-injection/apps/application.yaml @@ -3,6 +3,18 @@ services: class: ../../controllers/health/GetStatusController arguments: [] + Apps.apiApp.controllers.Auth.LoginController: + class: ../../controllers/Auth/LoginController + arguments: ['@apiApp.Auth.application.LoginUser'] + + Apps.apiApp.controllers.Auth.RegisterController: + class: ../../controllers/Auth/RegisterController + arguments: ['@apiApp.Auth.application.RegisterUser'] + + Apps.apiApp.controllers.Auth.ValidateMailController: + class: ../../controllers/Auth/ValidateMailController + arguments: ['@apiApp.Auth.application.ValidateMail'] + Apps.apiApp.controllers.Books.PostBookController: class: ../../controllers/Books/PostBookController arguments: ['@apiApp.Books.application.BookCreator'] diff --git a/src/apps/apiApp/routes/Auth/auth.routes.ts b/src/apps/apiApp/routes/Auth/auth.routes.ts new file mode 100644 index 0000000..614355e --- /dev/null +++ b/src/apps/apiApp/routes/Auth/auth.routes.ts @@ -0,0 +1,77 @@ +import { NextFunction, Request, Response, Router } from 'express'; +import { body, checkExact, param } from 'express-validator'; + +import container from '../../dependency-injection'; + +import { validateBody, validateReqSchema } from '../shared'; +import { + LoginController, + RegisterController, + ValidateMailController +} from '../../controllers/Auth'; + +const prefix = '/api/v1/Auth'; + +export const register = (router: Router) => { + const loginReqSchema = [ + body('email').exists().isEmail(), + body('password').exists().isString(), + checkExact() + ]; + + const registerReqSchema = [ + body('email').exists().isEmail(), + body('username').exists().isString(), + // body('password').exists().isStrongPassword(), + // body('repeatPassword').exists().isStrongPassword(), + body('password').exists().isString(), + body('repeatPassword').exists().isString(), + checkExact() + ]; + + const validateMailReqSchema = [ + param('token').exists().isString(), + checkExact() + ]; + + const loginController: LoginController = container.get( + 'Apps.apiApp.controllers.Auth.LoginController' + ); + + const registerController: RegisterController = container.get( + 'Apps.apiApp.controllers.Auth.RegisterController' + ); + + const validateMailController: ValidateMailController = container.get( + 'Apps.apiApp.controllers.Auth.ValidateMailController' + ); + + router.post( + `${prefix}/login`, + validateBody, + loginReqSchema, + validateReqSchema, + (req: Request, res: Response, next: NextFunction) => { + loginController.run(req, res, next); + } + ); + + router.post( + `${prefix}/register`, + validateBody, + registerReqSchema, + validateReqSchema, + (req: Request, res: Response, next: NextFunction) => { + registerController.run(req, res, next); + } + ); + + router.get( + `${prefix}/validate/:token`, + validateMailReqSchema, + validateReqSchema, + (req: Request, res: Response, next: NextFunction) => { + validateMailController.run(req, res, next); + } + ); +}; diff --git a/src/apps/apiApp/routes/shared/apiErrorHandler.ts b/src/apps/apiApp/routes/shared/apiErrorHandler.ts index 8f7e9c0..23a0850 100644 --- a/src/apps/apiApp/routes/shared/apiErrorHandler.ts +++ b/src/apps/apiApp/routes/shared/apiErrorHandler.ts @@ -4,6 +4,7 @@ import { NotFoundError } from '../../../../Contexts/shared/domain/errors/NotFoun import { InvalidArgumentError } from '../../../../Contexts/shared/domain/errors/InvalidArgumentError'; import { buildLogger } from '../../../../Contexts/shared/plugins/logger.plugin'; +import { AuthError } from '../../../../Contexts/shared/domain/errors/AuthError'; const logger = buildLogger('apiErrorHandler'); @@ -16,7 +17,11 @@ export const apiErrorHandler = ( let statusCode; let message = err.message; + // TODO: to avoid case explosion, we could use an custom error Factory, GH issue #121 switch (true) { + case err instanceof AuthError: + statusCode = httpStatus.UNAUTHORIZED; + break; case err instanceof NotFoundError: statusCode = httpStatus.NOT_FOUND; break; @@ -28,6 +33,11 @@ export const apiErrorHandler = ( message = 'Internal Server Error'; } - logger.error(message); + const stack = + statusCode === httpStatus.INTERNAL_SERVER_ERROR + ? `Stack: ${err.stack}` + : ''; + + logger.error(`Error: ${err.message}. ${stack}`); res.status(statusCode).json({ message }); }; diff --git a/src/apps/apiApp/routes/shared/validateReqSchema.ts b/src/apps/apiApp/routes/shared/validateReqSchema.ts index 43cc846..63facfe 100644 --- a/src/apps/apiApp/routes/shared/validateReqSchema.ts +++ b/src/apps/apiApp/routes/shared/validateReqSchema.ts @@ -28,7 +28,12 @@ export const validateReqSchema = ( error: FieldValidationError ): ValidationErrorInfo | null => { const errorInfoKey = error.path; - const errorInfoValue = `${error.msg} at ${error.location}. Value: ${error.value}`; + const hiddenFields = ['password', 'repeatPassword']; + const baseMessage = `${error.msg} at ${error.location}.`; + const errorInfoValue = hiddenFields.includes(errorInfoKey) + ? baseMessage + : `${baseMessage} Value: ${error.value}`; + return { [errorInfoKey]: errorInfoValue }; }; diff --git a/src/config/plugins/envs.plugin.ts b/src/config/plugins/envs.plugin.ts index fc58f08..40afaf0 100644 --- a/src/config/plugins/envs.plugin.ts +++ b/src/config/plugins/envs.plugin.ts @@ -10,5 +10,6 @@ export const envs = { MONGO_URL: env.get('MONGO_URL').required().asString(), MONGO_DB: env.get('MONGO_DB').required().asString(), MONGO_USERNAME: env.get('MONGO_USERNAME').required().asString(), - MONGO_PASSWORD: env.get('MONGO_PASSWORD').required().asString() + MONGO_PASSWORD: env.get('MONGO_PASSWORD').required().asString(), + JWT_SECRET: env.get('JWT_SECRET').required().asString() }; diff --git a/tests/Contexts/apiApp/Auth/__mocks__/CryptAdapterMock.ts b/tests/Contexts/apiApp/Auth/__mocks__/CryptAdapterMock.ts new file mode 100644 index 0000000..30e5e1a --- /dev/null +++ b/tests/Contexts/apiApp/Auth/__mocks__/CryptAdapterMock.ts @@ -0,0 +1,71 @@ +import { CryptAdapter } from '../../../../../src/Contexts/shared/plugins/CryptAdapter'; +import { Nullable } from '../../../../../src/Contexts/shared/domain/Nullable'; +import { random } from '../../../fixtures/shared'; +import { EmailMother } from '../../../shared/domain/mothers/EmailMother'; + +export class CryptAdapterMock implements CryptAdapter { + private hashMock: jest.Mock; + private compareMock: jest.Mock; + private generateTokenMock: jest.Mock; + private verifyTokenMock: jest.Mock; + private refreshTokenMock: jest.Mock; + + constructor( + { login, token }: { login?: boolean; token?: boolean } = { + login: false, + token: false + } + ) { + this.hashMock = jest.fn().mockReturnValue('encryptedPassword'); + this.compareMock = jest.fn().mockReturnValue(login); + this.generateTokenMock = jest.fn().mockReturnValue(random.word()); + this.verifyTokenMock = token + ? jest.fn().mockReturnValue({ email: EmailMother.random().value }) + : jest.fn().mockReturnValue(null); + this.refreshTokenMock = jest.fn().mockReturnValue(random.word()); + } + + hash(password: string): string { + return this.hashMock(password); + } + + assertHashHasBeenCalledWith(expected: string): void { + expect(this.hashMock).toHaveBeenCalledWith(expected); + } + + compare(value: string, encryptedValue: string): boolean { + return this.compareMock(value, encryptedValue); + } + + assertCompareHasBeenCalledWith( + expectedValue: string, + expectedEncryptedValue: string + ): void { + expect(this.compareMock).toHaveBeenCalledWith( + expectedValue, + expectedEncryptedValue + ); + } + + generateToken( + payload: Record, + duration?: string + ): Promise> { + return this.generateTokenMock(payload, duration); + } + verifyToken(token: string): Promise>> { + return this.verifyTokenMock(token); + } + + assertVerifyTokenHasBeenCalledWith(expected: string): void { + expect(this.verifyTokenMock).toHaveBeenCalledWith(expected); + } + + refreshToken(token: string): Promise> { + return this.refreshTokenMock(token); + } + + assertRefreshTokenHasBeenCalledWith(expected: string): void { + expect(this.refreshTokenMock).toHaveBeenCalledWith(expected); + } +} diff --git a/tests/Contexts/apiApp/Auth/__mocks__/UserRepositoryMock.ts b/tests/Contexts/apiApp/Auth/__mocks__/UserRepositoryMock.ts new file mode 100644 index 0000000..8a79e00 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/__mocks__/UserRepositoryMock.ts @@ -0,0 +1,54 @@ +import { Email, User } from '../../../../../src/Contexts/apiApp/Auth/domain'; +import { UserPatch } from '../../../../../src/Contexts/apiApp/Auth/domain/UserPatch'; +import { UserRepository } from '../../../../../src/Contexts/apiApp/Auth/domain/UserRepository'; +import { Nullable } from '../../../../../src/Contexts/shared/domain/Nullable'; +import { StringValueObject } from '../../../../../src/Contexts/shared/domain/value-object/StringValueObject'; +import { UserMother } from '../domain/mothers/UserMother'; + +export class UserRepositoryMock implements UserRepository { + private saveMock: jest.Mock; + private updateMock: jest.Mock; + private findMock: jest.Mock; + private password: StringValueObject = new StringValueObject( + '$2a$12$mZgfH4D7z4dZcZHDKyogqOOnEWS6XHLdczPJktzD88djpvlr3Bq1C' + ); + + constructor({ exists }: { exists: boolean }) { + if (exists) { + this.findMock = jest.fn().mockImplementation((email: string) => { + return UserMother.create({ + email: new Email(email), + password: this.password + }); + }); + } else { + this.findMock = jest.fn().mockReturnValue(null); + } + this.saveMock = jest.fn(); + this.updateMock = jest.fn(); + } + + async save(user: User): Promise { + this.saveMock(user); + } + + assertSaveHasBeenCalledWith(expected: User): void { + expect(this.saveMock).toHaveBeenCalledWith(expected); + } + + async update(user: UserPatch): Promise { + this.updateMock(user); + } + + assertUpdateHasBeenCalledWith(expected: UserPatch): void { + expect(this.updateMock).toHaveBeenCalledWith(expected); + } + + async search(email: string): Promise> { + return this.findMock(email); + } + + assertSearchHasBeenCalledWith(expected: string): void { + expect(this.findMock).toHaveBeenCalledWith(expected); + } +} diff --git a/tests/Contexts/apiApp/Auth/application/LoginUser.test.ts b/tests/Contexts/apiApp/Auth/application/LoginUser.test.ts new file mode 100644 index 0000000..2564fe0 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/application/LoginUser.test.ts @@ -0,0 +1,48 @@ +import { LoginUser } from '../../../../../src/Contexts/apiApp/Auth/application'; +import { CryptAdapterMock } from '../__mocks__/CryptAdapterMock'; +import { UserRepositoryMock } from '../__mocks__/UserRepositoryMock'; +import { LoginUserRequestMother } from './mothers/LoginUserRequestMother'; + +describe('LoginUser', () => { + let encrypter: CryptAdapterMock; + let repository: UserRepositoryMock; + let loginUser: LoginUser; + + beforeEach(() => { + encrypter = new CryptAdapterMock({ login: true }); + repository = new UserRepositoryMock({ exists: true }); + loginUser = new LoginUser(repository, encrypter); + }); + + it('should login a valid user', async () => { + const request = LoginUserRequestMother.random(); + + await loginUser.run(request); + + repository.assertSearchHasBeenCalledWith(request.email); + encrypter.assertCompareHasBeenCalledWith( + request.password, + expect.any(String) + ); + }); + + it('should throw an error when the user does not exist', async () => { + repository = new UserRepositoryMock({ exists: false }); + loginUser = new LoginUser(repository, encrypter); + const request = LoginUserRequestMother.random(); + + expect(async () => { + await loginUser.run(request); + }).rejects.toThrowError(`Invalid credentials`); + }); + + it('should throw an error when the password is invalid', async () => { + encrypter = new CryptAdapterMock({ login: false }); + loginUser = new LoginUser(repository, encrypter); + const request = LoginUserRequestMother.random(); + + expect(async () => { + await loginUser.run(request); + }).rejects.toThrowError(`Invalid credentials`); + }); +}); diff --git a/tests/Contexts/apiApp/Auth/application/RegisterUser.test.ts b/tests/Contexts/apiApp/Auth/application/RegisterUser.test.ts new file mode 100644 index 0000000..4221870 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/application/RegisterUser.test.ts @@ -0,0 +1,49 @@ +import { RegisterUser } from '../../../../../src/Contexts/apiApp/Auth/application'; +import { + Email, + UserRoles, + Username +} from '../../../../../src/Contexts/apiApp/Auth/domain'; +import { StringValueObject } from '../../../../../src/Contexts/shared/domain/value-object/StringValueObject'; +import { CryptAdapterMock } from '../__mocks__/CryptAdapterMock'; +import { UserRepositoryMock } from '../__mocks__/UserRepositoryMock'; +import { RegisterUserRequestMother } from './mothers/RegisterUserRequestMother'; + +describe('RegisterUser', () => { + let encrypter: CryptAdapterMock; + let repository: UserRepositoryMock; + let registerUser: RegisterUser; + + beforeEach(() => { + encrypter = new CryptAdapterMock({ login: false }); + repository = new UserRepositoryMock({ exists: false }); + registerUser = new RegisterUser(repository, encrypter); + }); + + it('should register a valid user', async () => { + const request = RegisterUserRequestMother.random(); + + await registerUser.run(request); + + repository.assertSearchHasBeenCalledWith(request.email); + repository.assertSaveHasBeenCalledWith( + expect.objectContaining({ + email: expect.any(Email), + username: expect.any(Username), + password: expect.any(StringValueObject), + emailValidated: expect.any(Boolean), + roles: expect.any(UserRoles) + }) + ); + }); + + it('should throw an error when the user already exists', async () => { + const request = RegisterUserRequestMother.random(); + repository = new UserRepositoryMock({ exists: true }); + registerUser = new RegisterUser(repository, encrypter); + + expect(async () => { + await registerUser.run(request); + }).rejects.toThrowError(`User <${request.email}> already exists`); + }); +}); diff --git a/tests/Contexts/apiApp/Auth/application/ValidateMail.test.ts b/tests/Contexts/apiApp/Auth/application/ValidateMail.test.ts new file mode 100644 index 0000000..3135023 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/application/ValidateMail.test.ts @@ -0,0 +1,44 @@ +import { ValidateMail } from '../../../../../src/Contexts/apiApp/Auth/application'; +import { UserPatch } from '../../../../../src/Contexts/apiApp/Auth/domain/UserPatch'; +import { random } from '../../../fixtures/shared'; +import { CryptAdapterMock } from '../__mocks__/CryptAdapterMock'; +import { UserRepositoryMock } from '../__mocks__/UserRepositoryMock'; + +describe('ValidateMail', () => { + let encrypter: CryptAdapterMock; + let repository: UserRepositoryMock; + let service: ValidateMail; + + beforeEach(() => { + encrypter = new CryptAdapterMock({ token: true }); + repository = new UserRepositoryMock({ exists: true }); + service = new ValidateMail(repository, encrypter); + }); + + it('should validate the user', async () => { + const token = random.word({ min: 6, max: 255 }); + + await service.run({ token }); + + encrypter.assertVerifyTokenHasBeenCalledWith(token); + repository.assertSearchHasBeenCalledWith(expect.any(String)); + repository.assertUpdateHasBeenCalledWith(expect.any(UserPatch)); + encrypter.assertRefreshTokenHasBeenCalledWith(token); + }); + + it('should throw an error if the token is invalid', async () => { + encrypter = new CryptAdapterMock({ token: false }); + service = new ValidateMail(repository, encrypter); + const token = random.word({ min: 6, max: 255 }); + + await expect(service.run({ token })).rejects.toThrowError('Invalid token'); + }); + + it('should throw an error if the user is not found', async () => { + repository = new UserRepositoryMock({ exists: false }); + service = new ValidateMail(repository, encrypter); + const token = random.word({ min: 6, max: 255 }); + + await expect(service.run({ token })).rejects.toThrowError('Invalid token'); + }); +}); diff --git a/tests/Contexts/apiApp/Auth/application/mothers/LoginUserRequestMother.ts b/tests/Contexts/apiApp/Auth/application/mothers/LoginUserRequestMother.ts new file mode 100644 index 0000000..9b38593 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/application/mothers/LoginUserRequestMother.ts @@ -0,0 +1,20 @@ +import { LoginUserRequest } from '../../../../../../src/Contexts/apiApp/Auth/application'; +import { Email } from '../../../../../../src/Contexts/apiApp/Auth/domain'; +import { StringValueObject } from '../../../../../../src/Contexts/shared/domain/value-object/StringValueObject'; +import { EmailMother } from '../../../../shared/domain/mothers/EmailMother'; + +export class LoginUserRequestMother { + static create(email: Email, password: StringValueObject): LoginUserRequest { + return { + email: email.value, + password: password.value + }; + } + + static random(): LoginUserRequest { + return this.create( + EmailMother.random(), + new StringValueObject('%aD3f3s.0%') + ); + } +} diff --git a/tests/Contexts/apiApp/Auth/application/mothers/RegisterUserRequestMother.ts b/tests/Contexts/apiApp/Auth/application/mothers/RegisterUserRequestMother.ts new file mode 100644 index 0000000..828dddd --- /dev/null +++ b/tests/Contexts/apiApp/Auth/application/mothers/RegisterUserRequestMother.ts @@ -0,0 +1,30 @@ +import { RegisterUserRequest } from '../../../../../../src/Contexts/apiApp/Auth/application/RegisterUserRequest'; +import { + Email, + Username +} from '../../../../../../src/Contexts/apiApp/Auth/domain'; +import { StringValueObject } from '../../../../../../src/Contexts/shared/domain/value-object/StringValueObject'; +import { random } from '../../../../fixtures/shared'; +import { EmailMother } from '../../../../shared/domain/mothers/EmailMother'; + +export class RegisterUserRequestMother { + static create( + email: Email, + username: Username, + password: StringValueObject + ): RegisterUserRequest { + return { + email: email.value, + username: username.value, + password: password.value + }; + } + + static random(): RegisterUserRequest { + return this.create( + EmailMother.random(), + new Username(random.word({ min: 4, max: 20 })), + new StringValueObject('%aD3f3s.0%') + ); + } +} diff --git a/tests/Contexts/apiApp/Auth/domain/User.test.ts b/tests/Contexts/apiApp/Auth/domain/User.test.ts new file mode 100644 index 0000000..dc2d2b9 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/domain/User.test.ts @@ -0,0 +1,49 @@ +import { User } from '../../../../../src/Contexts/apiApp/Auth/domain'; +import { UserMother } from './mothers/UserMother'; + +describe('User', () => { + it('should create a valid user', () => { + const user = UserMother.create(); + + expect(user).toBeInstanceOf(User); + expect(user.id).toBeDefined(); + expect(user.email).toBeDefined(); + expect(user.username).toBeDefined(); + expect(user.password).toBeDefined(); + expect(user.emailValidated).toBeDefined(); + expect(user.roles).toBeDefined(); + }); + + it('should return primitives from user', () => { + const user = UserMother.random(); + + const primitives = user.toPrimitives(); + + expect(primitives).toMatchObject({ + id: expect.any(String), + email: expect.any(String), + username: expect.any(String), + password: expect.any(String), + emailValidated: expect.any(Boolean), + roles: expect.any(Array) + }); + }); + + it('should create a valid user from primitives', () => { + const user = UserMother.random(); + + const userFromPrimitives = User.fromPrimitives( + user.toPrimitives() as { + id: string; + email: string; + username: string; + password: string; + emailValidated: boolean; + roles: string[]; + } + ); + + expect(userFromPrimitives).toBeInstanceOf(User); + expect(userFromPrimitives).toEqual(user); + }); +}); diff --git a/tests/Contexts/apiApp/Auth/domain/UserName.test.ts b/tests/Contexts/apiApp/Auth/domain/UserName.test.ts new file mode 100644 index 0000000..3f2702e --- /dev/null +++ b/tests/Contexts/apiApp/Auth/domain/UserName.test.ts @@ -0,0 +1,18 @@ +import { Username } from '../../../../../src/Contexts/apiApp/Auth/domain/Username'; +import { random } from '../../../fixtures/shared'; + +describe('UserName', () => { + it('should throw an error if user username is more than 20 chars long', () => { + const invalidUsername = random.word({ min: 21, max: 255 }); + expect(() => new Username(invalidUsername)).toThrowError( + ' must be less than 20 characters long' + ); + }); + + it('should throw an error if user username is less than 4 chars long', () => { + const invalidUsername = random.word({ min: 1, max: 3 }); + expect(() => new Username(invalidUsername)).toThrowError( + ' must be at least 4 characters long' + ); + }); +}); diff --git a/tests/Contexts/apiApp/Auth/domain/UserRoles.test.ts b/tests/Contexts/apiApp/Auth/domain/UserRoles.test.ts new file mode 100644 index 0000000..7cd04de --- /dev/null +++ b/tests/Contexts/apiApp/Auth/domain/UserRoles.test.ts @@ -0,0 +1,17 @@ +import { UserRoles } from '../../../../../src/Contexts/apiApp/Auth/domain'; +import { random } from '../../../fixtures/shared'; +import { UserRolesMother } from './mothers/UserRolesMother'; + +describe('UserRoles', () => { + it('should create a valid user roles', () => { + const userRoles = UserRolesMother.random(); + expect(userRoles).toBeInstanceOf(UserRoles); + }); + + it('should throw an error if user roles are invalid', () => { + const roles = [random.word()]; + expect(() => UserRolesMother.create(roles)).toThrowError( + ` does not allow the value <${roles}>` + ); + }); +}); diff --git a/tests/Contexts/apiApp/Auth/domain/mothers/UserMother.ts b/tests/Contexts/apiApp/Auth/domain/mothers/UserMother.ts new file mode 100644 index 0000000..6a24798 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/domain/mothers/UserMother.ts @@ -0,0 +1,63 @@ +import { RegisterUserRequest } from '../../../../../../src/Contexts/apiApp/Auth/application/RegisterUserRequest'; +import { + User, + Email, + UserRoles, + Username, + UserPatch +} from '../../../../../../src/Contexts/apiApp/Auth/domain'; +import { StringValueObject } from '../../../../../../src/Contexts/shared/domain/value-object/StringValueObject'; +import { Uuid } from '../../../../../../src/Contexts/shared/domain/value-object/Uuid'; +import { random } from '../../../../fixtures/shared'; +import { EmailMother } from '../../../../shared/domain/mothers/EmailMother'; +import { UserRolesMother } from './UserRolesMother'; + +export class UserMother { + static create({ + id, + email, + username, + password, + emailValidated, + roles + }: { + id?: Uuid; + email?: Email; + username?: Username; + password?: StringValueObject; + emailValidated?: boolean; + roles?: UserRoles; + } = {}): User { + return new User({ + id: id ?? Uuid.random(), + email: email ?? EmailMother.random(), + username: username ?? new Username(random.word({ min: 4, max: 20 })), + password: password ?? new StringValueObject(random.word()), + emailValidated: emailValidated ?? random.boolean(), + roles: + roles ?? + UserRolesMother.create([`${random.arrayElement(['admin', 'user'])}`]) + }); + } + + static from(command: RegisterUserRequest): User { + return this.create({ + email: new Email(command.email), + username: new Username(command.username), + password: new StringValueObject(command.password) + }); + } + + static random(): User { + return this.create(); + } + + static randomPatch(id: string): UserPatch { + return new UserPatch({ + id: new Uuid(id), + password: new StringValueObject(random.word()), + emailValidated: random.boolean(), + roles: UserRolesMother.random() + }); + } +} diff --git a/tests/Contexts/apiApp/Auth/domain/mothers/UserRolesMother.ts b/tests/Contexts/apiApp/Auth/domain/mothers/UserRolesMother.ts new file mode 100644 index 0000000..9069a78 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/domain/mothers/UserRolesMother.ts @@ -0,0 +1,12 @@ +import { UserRoles } from '../../../../../../src/Contexts/apiApp/Auth/domain'; +import { random } from '../../../../fixtures/shared'; + +export class UserRolesMother { + static create(value: string[]) { + return new UserRoles(value); + } + + static random() { + return this.create([`${random.arrayElement(['admin', 'user'])}`]); + } +} diff --git a/tests/Contexts/apiApp/Auth/domain/mothers/index.ts b/tests/Contexts/apiApp/Auth/domain/mothers/index.ts new file mode 100644 index 0000000..c56e656 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/domain/mothers/index.ts @@ -0,0 +1,2 @@ +export * from './UserMother'; +export * from './UserRolesMother'; diff --git a/tests/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.test.ts b/tests/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.test.ts new file mode 100644 index 0000000..72cecea --- /dev/null +++ b/tests/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.test.ts @@ -0,0 +1,62 @@ +import container from '../../../../../../src/apps/apiApp/dependency-injection'; +import { UserRepository } from '../../../../../../src/Contexts/apiApp/Auth/domain/UserRepository'; + +import { EnvironmentArranger } from '../../../../shared/infrastructure/arranger/EnvironmentArranger'; +import { UserMother } from '../../domain/mothers'; + +const repository: UserRepository = container.get( + 'apiApp.Auth.domain.AuthRepository' +); + +const environmentArranger: Promise = container.get( + 'apiApp.EnvironmentArranger' +); + +describe('MongoAuthRepository', () => { + beforeEach(async () => { + await (await environmentArranger).arrange(); + }); + + afterAll(async () => { + await (await environmentArranger).arrange(); + await (await environmentArranger).close(); + }); + + describe('save', () => { + it('should save a user', async () => { + const user = UserMother.random(); + + await repository.save(user); + }); + }); + + describe('update', () => { + it('should update an existing user', async () => { + const user = UserMother.random(); + await repository.save(user); + const userPatch = UserMother.randomPatch(user.id.value); + + await repository.update(userPatch); + + expect(await repository.search(user.email.value)).toMatchObject( + userPatch + ); + }); + }); + + describe('search', () => { + it('should return an existing user', async () => { + const user = UserMother.random(); + + await repository.save(user); + + expect(await repository.search(user.email.value)).toMatchObject(user); + }); + + it('should not return a non existing user', async () => { + expect(await repository.search(UserMother.random().email.value)).toBe( + null + ); + }); + }); +}); diff --git a/tests/Contexts/apiApp/Books/domain/Book.test.ts b/tests/Contexts/apiApp/Books/domain/Book.test.ts index 5a7c6ca..0aeb71a 100644 --- a/tests/Contexts/apiApp/Books/domain/Book.test.ts +++ b/tests/Contexts/apiApp/Books/domain/Book.test.ts @@ -60,7 +60,7 @@ describe('Book', () => { it('should throw an error when the title is longer than 100 characters', () => { let title; expect(() => { - title = new BookTitle(random.word({ min: 101 })); + title = new BookTitle(random.word({ min: 101, max: 255 })); }).toThrowError(InvalidArgumentError); expect(title).toBeUndefined(); diff --git a/tests/Contexts/apiApp/Books/domain/mothers/BookAuthorMother.ts b/tests/Contexts/apiApp/Books/domain/mothers/BookAuthorMother.ts index 98c4414..bff79ac 100644 --- a/tests/Contexts/apiApp/Books/domain/mothers/BookAuthorMother.ts +++ b/tests/Contexts/apiApp/Books/domain/mothers/BookAuthorMother.ts @@ -11,6 +11,6 @@ export class BookAuthorMother { } static invalidValue() { - return random.word({ min: 41 }); + return random.word({ min: 41, max: 255 }); } } diff --git a/tests/Contexts/apiApp/Books/domain/mothers/BookMother.ts b/tests/Contexts/apiApp/Books/domain/mothers/BookMother.ts index 53857ed..fff5086 100644 --- a/tests/Contexts/apiApp/Books/domain/mothers/BookMother.ts +++ b/tests/Contexts/apiApp/Books/domain/mothers/BookMother.ts @@ -2,12 +2,12 @@ import { BookCreatorRequest } from '../../../../../../src/Contexts/apiApp/Books/ import { Book, BookAuthor, - BookId, BookPages, BookReleaseDate, BookTitle, Isbn } from '../../../../../../src/Contexts/apiApp/Books/domain'; +import { Uuid } from '../../../../../../src/Contexts/shared/domain/value-object/Uuid'; import { BookAuthorMother } from './BookAuthorMother'; import { BookIdMother } from './BookIdMother'; @@ -18,7 +18,7 @@ import { ISBNMother } from './ISBNMother'; export class BookMother { static create( - id: BookId, + id: Uuid, title: BookTitle, author: BookAuthor, isbn: Isbn, diff --git a/tests/Contexts/apiApp/Books/domain/mothers/BookTitleMother.ts b/tests/Contexts/apiApp/Books/domain/mothers/BookTitleMother.ts index 0fb999a..2003f54 100644 --- a/tests/Contexts/apiApp/Books/domain/mothers/BookTitleMother.ts +++ b/tests/Contexts/apiApp/Books/domain/mothers/BookTitleMother.ts @@ -13,7 +13,7 @@ export class BookTitleMother { static invalidValue(): unknown { return random.arrayElement([ - random.word({ min: 101 }), + random.word({ min: 101, max: 255 }), random.integer(), random.boolean() ]); diff --git a/tests/Contexts/apiApp/Books/infraestructure/persistence/MongoBookRepository.test.ts b/tests/Contexts/apiApp/Books/infrastructure/persistence/MongoBookRepository.test.ts similarity index 100% rename from tests/Contexts/apiApp/Books/infraestructure/persistence/MongoBookRepository.test.ts rename to tests/Contexts/apiApp/Books/infrastructure/persistence/MongoBookRepository.test.ts diff --git a/tests/Contexts/fixtures/shared/random.ts b/tests/Contexts/fixtures/shared/random.ts index f7874a4..688ca36 100644 --- a/tests/Contexts/fixtures/shared/random.ts +++ b/tests/Contexts/fixtures/shared/random.ts @@ -43,7 +43,7 @@ export class Random { max = 256 }: { min?: number; max?: number } = {}): string { return this.chance.word({ - length: Math.floor(Math.random() * (max - min + 1)) + min + length: Math.floor(Math.random() * (max - min + 1) + min) }); } @@ -66,4 +66,8 @@ export class Random { return `${prefix}-${group1}-${group2}-${group3}-${group4}`; } + + public email(): string { + return this.chance.email(); + } } diff --git a/tests/Contexts/shared/domain/Email.test.ts b/tests/Contexts/shared/domain/Email.test.ts new file mode 100644 index 0000000..09a526f --- /dev/null +++ b/tests/Contexts/shared/domain/Email.test.ts @@ -0,0 +1,42 @@ +import { Email } from '../../../../src/Contexts/apiApp/Auth/domain'; +import { random } from '../../fixtures/shared'; +import { EmailMother } from './mothers/EmailMother'; + +describe('Email', () => { + it('should create a valid email', () => { + const email = EmailMother.random(); + expect(email).toBeInstanceOf(Email); + }); + + it('should throw an error if email is more than 255 chars long', () => { + const invalidEmail = random.word({ min: 256, max: 512 }); + expect(() => EmailMother.create(invalidEmail)).toThrowError( + ' must be less than 255 characters long' + ); + }); + + it('should throw an error if email is less than 6 chars long', () => { + const invalidEmail = random.word({ min: 1, max: 3 }); + expect(() => EmailMother.create(invalidEmail)).toThrowError( + ' must be at least 6 characters long' + ); + }); + + it('should throw an error if email is not an email address', () => { + const invalidEmail = random.word({ min: 6, max: 255 }); + expect(() => EmailMother.create(invalidEmail)).toThrowError( + ` does not allow the value <${invalidEmail}>` + ); + }); + + it('should throw an error if email domain is in the blackList', () => { + const invalidEmail = `test@${random.arrayElement([ + 'mailinator.com', + 'guerrillamail.com', + 'sharklasers.com' + ])}`; + expect(() => EmailMother.create(invalidEmail)).toThrowError( + ` does not allow the domain <${invalidEmail}>` + ); + }); +}); diff --git a/tests/Contexts/shared/domain/mothers/EmailMother.ts b/tests/Contexts/shared/domain/mothers/EmailMother.ts new file mode 100644 index 0000000..03a75ec --- /dev/null +++ b/tests/Contexts/shared/domain/mothers/EmailMother.ts @@ -0,0 +1,12 @@ +import { Email } from '../../../../../src/Contexts/apiApp/Auth/domain'; +import { random } from '../../../fixtures/shared'; + +export class EmailMother { + static create(value: string) { + return new Email(value); + } + + static random() { + return this.create(random.email()); + } +} diff --git a/tests/Contexts/shared/plugins/CryptAdapter.test.ts b/tests/Contexts/shared/plugins/CryptAdapter.test.ts new file mode 100644 index 0000000..27e7593 --- /dev/null +++ b/tests/Contexts/shared/plugins/CryptAdapter.test.ts @@ -0,0 +1,87 @@ +import { CryptAdapter } from '../../../../src/Contexts/shared/plugins/CryptAdapter'; +import { random } from '../../fixtures/shared'; + +describe('CryptAdapter', () => { + const cryptAdapter = new CryptAdapter(); + + describe('hash', () => { + it('should return a hash', () => { + const password = random.word(); + + const hash = cryptAdapter.hash(password); + + expect(hash).toBeDefined(); + expect(hash).not.toBe(password); + }); + }); + + describe('compare', () => { + it('should return true if password and hash match', () => { + const password = random.word(); + const hash = cryptAdapter.hash(password); + + expect(cryptAdapter.compare(password, hash)).toBe(true); + }); + + it('should return false if password and hash do not match', () => { + const password = random.word(); + const unMatchedPassword = random.word(); + const hash = cryptAdapter.hash(password); + + expect(cryptAdapter.compare(unMatchedPassword, hash)).toBe(false); + }); + }); + + describe('generateToken', () => { + it('should return a token with default duration', async () => { + const payload = { id: random.uuid() }; + const token = await cryptAdapter.generateToken(payload); + + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + }); + + it('should return a token with custom duration', async () => { + const payload = { id: random.uuid() }; + const duration = '1h'; + + const token = await cryptAdapter.generateToken(payload, duration); + + expect(token).toBeDefined(); + }); + }); + + describe('verifyToken', () => { + it('should return a payload', async () => { + const seed = { id: random.uuid() }; + const token = await cryptAdapter.generateToken(seed); + + const payload = await cryptAdapter.verifyToken(token as string); + + expect(payload).toMatchObject(seed); + }); + }); + + describe('refreshToken', () => { + it('should return a new token', async () => { + const seed = { id: random.uuid() }; + const duration = '12h'; + const token = await cryptAdapter.generateToken(seed, duration); + + const newToken = await cryptAdapter.refreshToken(token as string); + + expect(newToken).toBeDefined(); + expect(newToken).not.toBe(token); + }); + + it('should return null if token has expired', async () => { + const seed = { id: random.uuid() }; + const duration = '0s'; + const token = await cryptAdapter.generateToken(seed, duration); + + const newToken = await cryptAdapter.refreshToken(token as string); + + expect(newToken).toBeNull(); + }); + }); +}); diff --git a/tests/apps/apiApp/controllers/Auth/LoginController.test.ts b/tests/apps/apiApp/controllers/Auth/LoginController.test.ts new file mode 100644 index 0000000..8463265 --- /dev/null +++ b/tests/apps/apiApp/controllers/Auth/LoginController.test.ts @@ -0,0 +1,56 @@ +import { Request, Response } from 'express'; +import httpStatus from 'http-status'; +import { + LoginUser, + LoginUserRequest +} from '../../../../../src/Contexts/apiApp/Auth/application'; +import { LoginController } from '../../../../../src/apps/apiApp/controllers/Auth'; +import { UserRepositoryMock } from '../../../../Contexts/apiApp/Auth/__mocks__/UserRepositoryMock'; +import { CryptAdapterMock } from '../../../../Contexts/apiApp/Auth/__mocks__/CryptAdapterMock'; +import { LoginUserRequestMother } from '../../../../Contexts/apiApp/Auth/application/mothers/LoginUserRequestMother'; +import { AuthError } from '../../../../../src/Contexts/shared/domain/errors/AuthError'; + +describe('LoginController', () => { + let repository: UserRepositoryMock; + let encrypter: CryptAdapterMock; + let controller: LoginController; + let service: LoginUser; + let request: LoginUserRequest; + + let req: Partial; + let res: Partial; + let next: jest.Mock; + + const spyService = jest.spyOn(LoginUser.prototype, 'run'); + + beforeEach(() => { + repository = new UserRepositoryMock({ exists: true }); + encrypter = new CryptAdapterMock({ login: true, token: true }); + service = new LoginUser(repository, encrypter); + controller = new LoginController(service); + request = LoginUserRequestMother.random(); + req = { body: request }; + res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + next = jest.fn(); + }); + + describe('run', () => { + it('should login the user and send 200 status', async () => { + await controller.run(req as Request, res as Response, next); + + expect(spyService).toHaveBeenCalledWith(request); + expect(res.status).toHaveBeenCalledWith(httpStatus.OK); + expect(res.json).toHaveBeenCalledWith({ token: expect.any(String) }); + }); + + it('should call next with the AuthError if login fails', async () => { + encrypter = new CryptAdapterMock({ login: false }); + service = new LoginUser(repository, encrypter); + controller = new LoginController(service); + + await controller.run(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledWith(expect.any(AuthError)); + }); + }); +}); diff --git a/tests/apps/apiApp/controllers/Auth/RegisterController.test.ts b/tests/apps/apiApp/controllers/Auth/RegisterController.test.ts new file mode 100644 index 0000000..f69cabc --- /dev/null +++ b/tests/apps/apiApp/controllers/Auth/RegisterController.test.ts @@ -0,0 +1,62 @@ +import httpStatus from 'http-status'; +import { Request, Response } from 'express'; +import { RegisterUser } from '../../../../../src/Contexts/apiApp/Auth/application'; +import { RegisterUserRequest } from '../../../../../src/Contexts/apiApp/Auth/application/RegisterUserRequest'; +import { RegisterController } from '../../../../../src/apps/apiApp/controllers/Auth'; +import { CryptAdapterMock } from '../../../../Contexts/apiApp/Auth/__mocks__/CryptAdapterMock'; +import { UserRepositoryMock } from '../../../../Contexts/apiApp/Auth/__mocks__/UserRepositoryMock'; +import { RegisterUserRequestMother } from '../../../../Contexts/apiApp/Auth/application/mothers/RegisterUserRequestMother'; +import { InvalidArgumentError } from '../../../../../src/Contexts/shared/domain/errors/InvalidArgumentError'; + +describe('RegisterController', () => { + let repository: UserRepositoryMock; + let encrypter: CryptAdapterMock; + let controller: RegisterController; + let service: RegisterUser; + let request: RegisterUserRequest; + + let req: Partial; + let res: Partial; + let next: jest.Mock; + + const spyService = jest.spyOn(RegisterUser.prototype, 'run'); + + beforeEach(() => { + repository = new UserRepositoryMock({ exists: false }); + encrypter = new CryptAdapterMock({ token: true }); + service = new RegisterUser(repository, encrypter); + controller = new RegisterController(service); + request = RegisterUserRequestMother.random(); + req = { body: { ...request, repeatPassword: request.password } }; + res = { status: jest.fn().mockReturnThis(), send: jest.fn() }; + next = jest.fn(); + }); + + describe('run', () => { + it('should register the user and send 201 status', async () => { + await controller.run(req as Request, res as Response, next); + + expect(spyService).toHaveBeenCalledWith(request); + expect(res.status).toHaveBeenCalledWith(httpStatus.CREATED); + expect(res.send).toHaveBeenCalledWith(); + }); + + it("should fail if passwords don't match", async () => { + req = { body: { ...request, repeatPassword: 'differentPassword' } }; + + await controller.run(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledWith(expect.any(InvalidArgumentError)); + }); + + it('should fail if user exists', async () => { + repository = new UserRepositoryMock({ exists: true }); + service = new RegisterUser(repository, encrypter); + controller = new RegisterController(service); + + await controller.run(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledWith(expect.any(InvalidArgumentError)); + }); + }); +}); diff --git a/tests/apps/apiApp/controllers/Auth/ValidateMailControllet.test.ts b/tests/apps/apiApp/controllers/Auth/ValidateMailControllet.test.ts new file mode 100644 index 0000000..c81b547 --- /dev/null +++ b/tests/apps/apiApp/controllers/Auth/ValidateMailControllet.test.ts @@ -0,0 +1,52 @@ +import { Request, Response } from 'express'; +import httpStatus from 'http-status'; +import { UserRepositoryMock } from '../../../../Contexts/apiApp/Auth/__mocks__/UserRepositoryMock'; +import { ValidateMailController } from '../../../../../src/apps/apiApp/controllers/Auth'; +import { ValidateMail } from '../../../../../src/Contexts/apiApp/Auth/application'; +import { CryptAdapterMock } from '../../../../Contexts/apiApp/Auth/__mocks__/CryptAdapterMock'; +import { AuthError } from '../../../../../src/Contexts/shared/domain/errors/AuthError'; + +describe('ValidateMailController', () => { + let repository: UserRepositoryMock; + let encrypter: CryptAdapterMock; + let controller: ValidateMailController; + let service: ValidateMail; + let request: { token: string }; + + let req: Partial; + let res: Partial; + let next: jest.Mock; + + const spyService = jest.spyOn(ValidateMail.prototype, 'run'); + + beforeEach(() => { + repository = new UserRepositoryMock({ exists: true }); + encrypter = new CryptAdapterMock({ login: true, token: true }); + service = new ValidateMail(repository, encrypter); + controller = new ValidateMailController(service); + request = { token: 'token' }; + req = { params: request }; + res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + next = jest.fn(); + }); + + describe('run', () => { + it('should validate the mail and send 200 status', async () => { + await controller.run(req as Request, res as Response, next); + + expect(spyService).toHaveBeenCalledWith(request); + expect(res.status).toHaveBeenCalledWith(httpStatus.OK); + expect(res.json).toHaveBeenCalledWith({ token: expect.any(String) }); + }); + + it('should call next with the AuthError if login fails', async () => { + encrypter = new CryptAdapterMock({ login: false }); + service = new ValidateMail(repository, encrypter); + controller = new ValidateMailController(service); + + await controller.run(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledWith(expect.any(AuthError)); + }); + }); +});