diff --git a/models/password.js b/models/password.js new file mode 100644 index 0000000..78699da --- /dev/null +++ b/models/password.js @@ -0,0 +1,21 @@ +import bcryptjs from "bcryptjs"; + +async function hash(password) { + const rounds = getNumberOfRounds(); + return await bcryptjs.hash(password, 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 6779be7..bbfb873 100644 --- a/models/user.js +++ b/models/user.js @@ -1,4 +1,5 @@ import database from "infra/database.js"; +import password from "models/password.js"; import { ValidationError, NotFoundError } from "infra/errors.js"; async function findOneByUsername(username) { @@ -33,77 +34,131 @@ async function findOneByUsername(username) { } async function create(userInputValues) { - await validateUniqueEmail(userInputValues.email); await validateUniqueUsername(userInputValues.username); + await validateUniqueEmail(userInputValues.email); + 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, + ], }); + return results.rows[0]; + } +} - if (results.rowCount > 0) { - throw new ValidationError({ - message: "O email informado já está sendo utilizado.", - action: "Utilize outro email para realizar o cadastro.", - }); - } +async function update(username, userInputValues) { + const currentUser = await findOneByUsername(username); + + if ("username" in userInputValues) { + await validateUniqueUsername(userInputValues.username); } - async function validateUniqueUsername(username) { - const results = await database.query({ - text: ` - SELECT - username - FROM - users - WHERE - LOWER(username) = LOWER($1) - ;`, - values: [username], - }); + if ("email" in userInputValues) { + await validateUniqueEmail(userInputValues.email); + } - if (results.rowCount > 0) { - throw new ValidationError({ - message: "O username informado já está sendo utilizado.", - action: "Utilize outro username para realizar o cadastro.", - }); - } + if ("password" in userInputValues) { + await hashPasswordInObject(userInputValues); } - async function runInsertQuery(userInputValues) { + const userWithNewValues = { ...currentUser, ...userInputValues }; + + const updatedUser = await runUpdateQuery(userWithNewValues); + return updatedUser; + + async function runUpdateQuery(userWithNewValues) { const results = await database.query({ text: ` - INSERT INTO - users (username, email, password) - VALUES - ($1, $2, $3) + UPDATE + users + SET + username = $2, + email = $3, + password = $4, + updated_at = timezone('utc', now()) + WHERE + id = $1 RETURNING * - ;`, + `, values: [ - userInputValues.username, - userInputValues.email, - userInputValues.password, + 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 + users + WHERE + LOWER(username) = LOWER($1) + ;`, + values: [username], + }); + + if (results.rowCount > 0) { + throw new ValidationError({ + message: "O username informado já está sendo utilizado.", + action: "Utilize outro username para realizar esta operação.", + }); + } +} + +async function validateUniqueEmail(email) { + const results = await database.query({ + text: ` + SELECT + email + FROM + users + WHERE + LOWER(email) = LOWER($1) + ;`, + values: [email], + }); + + if (results.rowCount > 0) { + throw new ValidationError({ + message: "O email informado já está sendo utilizado.", + action: "Utilize outro email para realizar esta operação.", + }); + } +} + +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 0a0a609..8195d88 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": "16.4.4", "dotenv-expand": "11.0.6", "next": "13.1.6", @@ -2915,6 +2916,14 @@ } ] }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", diff --git a/package.json b/package.json index 5f8417b..fb86f8a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "license": "MIT", "dependencies": { "async-retry": "1.3.3", + "bcryptjs": "3.0.2", "dotenv": "16.4.4", "dotenv-expand": "11.0.6", "next": "13.1.6", diff --git a/pages/api/v1/users/[username]/index.js b/pages/api/v1/users/[username]/index.js index 3181d03..cec67eb 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -5,6 +5,7 @@ import user from "models/user.js"; const router = createRouter(); router.get(getHandler); +router.patch(patchHandler); export default router.handler(controller.errorHandlers); @@ -13,3 +14,11 @@ async function getHandler(request, response) { const userFound = await user.findOneByUsername(username); return response.status(200).json(userFound); } + +async function patchHandler(request, response) { + const username = request.query.username; + const userInputValues = request.body; + + const updatedUser = await user.update(username, userInputValues); + return response.status(200).json(updatedUser); +} diff --git a/tests/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js index d2b5d65..32a0165 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: "MesmoCase", email: "mesmo.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: "CaseDiferente", email: "case.diferente@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..3a252ac --- /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/UsuarioInexistente", + { + method: "PATCH", + } + ); + + expect(response.status).toBe(404); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "NotFoundError", + message: "O username informado não foi encontrado no sistema.", + action: "Verifique se o username está digitado corretamente.", + 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: "O username informado já está sendo utilizado.", + action: "Utilize outro username para realizar esta operação.", + status_code: 400, + }); + }); + + test("With duplicated 'email'", async () => { + const user1Response = 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(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: "email2", + email: "email2@curso.dev", + password: "senha123", + }), + }); + + expect(user2Response.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: "O email informado já está sendo utilizado.", + action: "Utilize outro email para realizar esta operação.", + 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 correctPasswordMatch = await password.compare( + "newPassword2", + userInDatabase.password + ); + + const incorrectPasswordMatch = await password.compare( + "newPassword1", + userInDatabase.password + ); + + expect(correctPasswordMatch).toBe(true); + expect(incorrectPasswordMatch).toBe(false); + }); + }); +}); diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index ea52a8a..f49123a 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(); @@ -30,7 +32,7 @@ describe("POST /api/v1/users", () => { id: responseBody.id, username: "filipedeschamps", email: "contato@curso.dev", - password: "senha123", + password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -38,6 +40,20 @@ describe("POST /api/v1/users", () => { expect(uuidVersion(responseBody.id)).toBe(4); expect(Date.parse(responseBody.created_at)).not.toBeNaN(); expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + + const userInDatabase = await user.findOneByUsername("filipedeschamps"); + const correctPasswordMatch = await password.compare( + "senha123", + userInDatabase.password + ); + + const incorrectPasswordMatch = await password.compare( + "SenhaErrada", + userInDatabase.password + ); + + expect(correctPasswordMatch).toBe(true); + expect(incorrectPasswordMatch).toBe(false); }); test("With duplicated 'email'", async () => { @@ -74,7 +90,7 @@ describe("POST /api/v1/users", () => { expect(response2Body).toEqual({ name: "ValidationError", message: "O email informado já está sendo utilizado.", - action: "Utilize outro email para realizar o cadastro.", + action: "Utilize outro email para realizar esta operação.", status_code: 400, }); }); @@ -113,7 +129,7 @@ describe("POST /api/v1/users", () => { expect(response2Body).toEqual({ name: "ValidationError", message: "O username informado já está sendo utilizado.", - action: "Utilize outro username para realizar o cadastro.", + action: "Utilize outro username para realizar esta operação.", status_code: 400, }); });