diff --git a/models/password.js b/models/password.js index e69de29..a38b497 100644 --- a/models/password.js +++ b/models/password.js @@ -0,0 +1,23 @@ +import bcryptjs from "bcryptjs"; + +const pepper = process.env.PASSWORD_PEPPER || ""; + +async function hash(password) { + const rounds = getNumberofRounds(); + return await bcryptjs.hash(password + pepper, rounds); +} + +function getNumberofRounds() { + return process.env.NODE_ENV === "production" ? 14 : 1; +} + +async function compare(providedPassword, storedPassword) { + return await bcryptjs.compare(providedPassword, storedPassword); +} + +const password = { + hash, + compare, +}; + +export default password; diff --git a/models/user.js b/models/user.js index 369b7c4..fe5e517 100644 --- a/models/user.js +++ b/models/user.js @@ -1,4 +1,5 @@ import database from "infra/database"; +import password from "models/password.js"; import { ValidationError, NotFoundError } from "infra/errors.js"; async function findOneByUsername(username) { @@ -33,37 +34,83 @@ async function findOneByUsername(username) { } async function create(userInputValues) { + await validateUniqueUsername(userInputValues.username); + await validateUniqueEmail(userInputValues.email); - await validateUniqueUsername(userInputValues.username); + await hashPasswordInObject(userInputValues); const newUser = await runInsertQuery(userInputValues); return newUser; - async function validateUniqueEmail(email) { + async function runInsertQuery(userInputValues) { const results = await database.query({ text: ` - SELECT - email - FROM - users - WHERE - LOWER(email) = LOWER($1) + INSERT INTO + users (username, email, password) + VALUES + ($1, $2, $3) + RETURNING * ;`, - values: [email], + values: [ + userInputValues.username, + userInputValues.email, + userInputValues.password, + ], }); - if (results.rowCount > 0) { - throw new ValidationError({ - message: "Email already in use", - action: "Please change the email and try again", - }); - } + return results.rows[0]; + } +} + +async function update(username, userInputValues) { + const currentUser = await findOneByUsername(username); + + if ("username" in userInputValues) { + await validateUniqueUsername(userInputValues.username); + } + + if ("email" in userInputValues) { + await validateUniqueEmail(userInputValues.email); + } + + if ("password" in userInputValues) { + await hashPasswordInObject(userInputValues); } - async function validateUniqueUsername(username) { + const userWithNewValues = { ...currentUser, ...userInputValues }; + const updatedUser = await runUpdateQuery(userWithNewValues); + return updatedUser; + + async function runUpdateQuery(userWithNewValues) { const results = await database.query({ text: ` + UPDATE + users + SET + username = $2, + email = $3, + password = $4, + updated_at = timezone('utc'::text, now()) + WHERE + id = $1 + RETURNING * + ;`, + values: [ + userWithNewValues.id, + userWithNewValues.username, + userWithNewValues.email, + userWithNewValues.password, + ], + }); + + return results.rows[0]; + } +} + +async function validateUniqueUsername(username) { + const results = await database.query({ + text: ` SELECT username FROM @@ -71,40 +118,47 @@ async function create(userInputValues) { WHERE LOWER(username) = LOWER($1) ;`, - values: [username], - }); + values: [username], + }); - if (results.rowCount > 0) { - throw new ValidationError({ - message: "Username already in use", - action: "Please change the username and try again", - }); - } + if (results.rowCount > 0) { + throw new ValidationError({ + message: "Username already in use", + action: "Please change the username and try again", + }); } +} - async function runInsertQuery(userInputValues) { - const results = await database.query({ - text: ` - INSERT INTO - users (username, email, password) - VALUES - ($1, $2, $3) - RETURNING * +async function validateUniqueEmail(email) { + const results = await database.query({ + text: ` + SELECT + email + FROM + users + WHERE + LOWER(email) = LOWER($1) ;`, - values: [ - userInputValues.username, - userInputValues.email, - userInputValues.password, - ], - }); + values: [email], + }); - return results.rows[0]; + if (results.rowCount > 0) { + throw new ValidationError({ + message: "Email already in use", + action: "Please change the email and try again", + }); } } +async function hashPasswordInObject(userInputValues) { + const hashedPassword = await password.hash(userInputValues.password); + userInputValues.password = hashedPassword; +} + const user = { create, findOneByUsername, + update, }; export default user; diff --git a/package-lock.json b/package-lock.json index 7d04dca..9b48b9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "async-retry": "1.3.3", + "bcryptjs": "3.0.2", "dotenv": "17.2.0", "dotenv-expand": "12.0.2", "eslint": "9.31.0", @@ -1099,9 +1100,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1829,9 +1830,10 @@ } }, "node_modules/@jest/core/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -2082,9 +2084,10 @@ } }, "node_modules/@jest/reporters/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -3753,6 +3756,15 @@ ], "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -4357,9 +4369,9 @@ "license": "Python-2.0" }, "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -7175,9 +7187,10 @@ } }, "node_modules/jest-cli/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -7560,9 +7573,10 @@ } }, "node_modules/jest-runtime/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -7774,9 +7788,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 5098da2..9f0ba6f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "description": "", "dependencies": { "async-retry": "1.3.3", + "bcryptjs": "3.0.2", "dotenv": "17.2.0", "dotenv-expand": "12.0.2", "eslint": "9.31.0", diff --git a/pages/api/v1/users/[username]/index.js b/pages/api/v1/users/[username]/index.js index 342f72f..ce714de 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -4,14 +4,24 @@ import user from "models/user.js"; const router = createRouter(); -router.get(getMigrationsHandler); +router.get(getHandler); +router.patch(patchHandler); -export default router.handler(controller.errorHandlers); - -async function getMigrationsHandler(request, response) { +async function getHandler(request, response) { // api/v1/users/[username] const username = request.query.username; const userFound = await user.findOneByUsername(username); return response.status(200).json(userFound); } + +async function patchHandler(request, response) { + // api/v1/users/[username] + const username = request.query.username; + const userInputValues = request.body; + + const updatedUser = await user.update(username, userInputValues); + return response.status(200).json(updatedUser); +} + +export default router.handler(controller.errorHandlers); diff --git a/tests/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js index 07c6b1d..07bd692 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -36,7 +36,7 @@ describe("GET /api/v1/users/[username]", () => { id: response2Body.id, username: "CamelCaseUser", email: "snake_case@curso.dev", - password: "senha123", + password: response2Body.password, created_at: response2Body.created_at, updated_at: response2Body.updated_at, }); @@ -73,7 +73,7 @@ describe("GET /api/v1/users/[username]", () => { id: response2Body.id, username: "DifferentCaseUser", email: "different_case@curso.dev", - password: "senha123", + password: response2Body.password, created_at: response2Body.created_at, updated_at: response2Body.updated_at, }); diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js new file mode 100644 index 0000000..2ae9749 --- /dev/null +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -0,0 +1,297 @@ +import { version as uuidVersion } from "uuid"; +import orchestrator from "tests/orchestrator.js"; +import user from "models/user.js"; +import password from "models/password.js"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("PATCH /api/v1/users/[username]", () => { + describe("Anonymous user", () => { + test("With nonexistent 'username'", async () => { + const response = await fetch( + "http://localhost:3000/api/v1/users/NonExistentUser", + { + method: "PATCH", + }, + ); + + expect(response.status).toBe(404); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "NotFoundError", + message: "User not found", + action: "Please check the username and try again", + status_code: 404, + }); + }); + + test("With duplicated 'username'", async () => { + const user1Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "user1", + email: "user1@curso.dev", + password: "senha123", + }), + }); + + expect(user1Response.status).toBe(201); + + const user2Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "user2", + email: "user2@curso.dev", + password: "senha123", + }), + }); + + expect(user2Response.status).toBe(201); + + const response = await fetch("http://localhost:3000/api/v1/users/user2", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "user1", + }), + }); + + expect(response.status).toBe(400); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ValidationError", + message: "Username already in use", + action: "Please change the username and try again", + status_code: 400, + }); + }); + + test("With duplicated 'email'", async () => { + const email1Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "email1", + email: "email1@curso.dev", + password: "senha123", + }), + }); + + expect(email1Response.status).toBe(201); + + const email2Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "email2", + email: "email2@curso.dev", + password: "senha123", + }), + }); + + expect(email2Response.status).toBe(201); + + const response = await fetch( + "http://localhost:3000/api/v1/users/email2", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "email1@curso.dev", + }), + }, + ); + + expect(response.status).toBe(400); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ValidationError", + message: "Email already in use", + action: "Please change the email and try again", + status_code: 400, + }); + }); + + test("With unique 'username'", async () => { + const user1Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "uniqueUser1", + email: "uniqueUser1@curso.dev", + password: "senha123", + }), + }); + + expect(user1Response.status).toBe(201); + + const response = await fetch( + "http://localhost:3000/api/v1/users/uniqueUser1", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "uniqueUser2", + }), + }, + ); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: responseBody.id, + username: "uniqueUser2", + email: "uniqueUser1@curso.dev", + password: responseBody.password, + created_at: responseBody.created_at, + updated_at: responseBody.updated_at, + }); + + expect(uuidVersion(responseBody.id)).toBe(4); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + + expect(responseBody.updated_at > responseBody.created_at).toBe(true); + }); + + test("With unique 'email'", async () => { + const user1Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "uniqueEmail1", + email: "uniqueEmail1@curso.dev", + password: "senha123", + }), + }); + + expect(user1Response.status).toBe(201); + + const response = await fetch( + "http://localhost:3000/api/v1/users/uniqueEmail1", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "uniqueEmail2@curso.dev", + }), + }, + ); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: responseBody.id, + username: "uniqueEmail1", + email: "uniqueEmail2@curso.dev", + password: responseBody.password, + created_at: responseBody.created_at, + updated_at: responseBody.updated_at, + }); + + expect(uuidVersion(responseBody.id)).toBe(4); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + + expect(responseBody.updated_at > responseBody.created_at).toBe(true); + }); + + test("With new 'password'", async () => { + const user1Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "newPassword1", + email: "newPassword1@curso.dev", + password: "newPassword1", + }), + }); + + expect(user1Response.status).toBe(201); + + const response = await fetch( + "http://localhost:3000/api/v1/users/newPassword1", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + password: "newPassword2", + }), + }, + ); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: responseBody.id, + username: "newPassword1", + email: "newPassword1@curso.dev", + password: responseBody.password, + created_at: responseBody.created_at, + updated_at: responseBody.updated_at, + }); + + expect(uuidVersion(responseBody.id)).toBe(4); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + + expect(responseBody.updated_at > responseBody.created_at).toBe(true); + + const userInDatabase = await user.findOneByUsername("newPassword1"); + const correctPassowordMatch = await password.compare( + "newPassword2", + userInDatabase.password, + ); + + const incorrectPassowordMatch = await password.compare( + "newPassword1", + userInDatabase.password, + ); + + expect(correctPassowordMatch).toBe(true); + expect(incorrectPassowordMatch).toBe(false); + }); + }); +}); diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index 799c476..7919479 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -1,5 +1,7 @@ import { version as uuidVersion } from "uuid"; import orchestrator from "tests/orchestrator.js"; +import user from "models/user.js"; +import password from "models/password.js"; beforeAll(async () => { await orchestrator.waitForAllServices(); @@ -33,7 +35,7 @@ describe("POST /api/v1/users", () => { id: responseBody.id, username: "bitsdonerd", email: "bitsdonerd@gmail.com", - password: "senha123", + password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, // tunelamento da resposta para ser ela mesma }); @@ -41,6 +43,20 @@ describe("POST /api/v1/users", () => { expect(uuidVersion(responseBody.id)).toBe(4); // Verifica se o ID é um UUID v4 expect(Date.parse(responseBody.created_at)).not.toBeNaN(); // Verifica se created_at é uma data válida expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + + const userInDatabase = await user.findOneByUsername("bitsdonerd"); + + const correctPassowordMatch = await password.compare( + "senha123", + userInDatabase.password, + ); // Verifica se a senha foi corretamente hasheada e armazenada no banco de dados + expect(correctPassowordMatch).toBe(true); + + const incorrectPassowordMatch = await password.compare( + "senhaErrada", + userInDatabase.password, + ); + expect(incorrectPassowordMatch).toBe(false); }); test("With duplicated email", async () => {