diff --git a/package-lock.json b/package-lock.json index bf9e91b6..0e69842a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", "multer": "^2.1.1", + "nodemailer": "^8.0.7", "nodemon": "^3.1.14", "pg": "^8.19.0", "swagger-jsdoc": "^6.2.8", @@ -4303,6 +4304,15 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", diff --git a/package.json b/package.json index 45d07221..b3244333 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", "multer": "^2.1.1", + "nodemailer": "^8.0.7", "nodemon": "^3.1.14", "pg": "^8.19.0", "swagger-jsdoc": "^6.2.8", diff --git a/prisma/migrations/20260520124718_add_password_reset_token/migration.sql b/prisma/migrations/20260520124718_add_password_reset_token/migration.sql new file mode 100644 index 00000000..521c83cf --- /dev/null +++ b/prisma/migrations/20260520124718_add_password_reset_token/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Users" ADD COLUMN "password_reset_token" VARCHAR(255), +ADD COLUMN "password_reset_token_expires" TIMESTAMPTZ; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 28a9bacc..235305e1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,10 +22,12 @@ model Users { email String @unique @db.VarChar(100) password_hash String @db.VarChar(255) phone String? @db.VarChar(20) - avatar_url String? @db.VarChar(500) - status Boolean @default(true) - created_at DateTime @default(now()) @db.Timestamptz - updated_at DateTime @updatedAt @db.Timestamptz + avatar_url String? @db.VarChar(500) + status Boolean @default(true) + password_reset_token String? @db.VarChar(255) + password_reset_token_expires DateTime? @db.Timestamptz + created_at DateTime @default(now()) @db.Timestamptz + updated_at DateTime @updatedAt @db.Timestamptz //Relaciones store Stores? //Usuario solo puede tener asociado un comercio diff --git a/src/lib/email.service.js b/src/lib/email.service.js new file mode 100644 index 00000000..b8b6c062 --- /dev/null +++ b/src/lib/email.service.js @@ -0,0 +1,37 @@ +import nodemailer from "nodemailer"; + +const transporter = nodemailer.createTransport({ + host: process.env.EMAIL_HOST, + port: parseInt(process.env.EMAIL_PORT || "587"), + secure: process.env.EMAIL_SECURE === "true", + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, +}); + +export const sendPasswordResetEmail = async (email, token) => { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const resetUrl = `${frontendUrl}/restablecer-contrasena/${token}`; + + await transporter.sendMail({ + from: `"OpenMarket" <${process.env.EMAIL_USER}>`, + to: email, + subject: "Recuperación de contraseña", + html: ` +
+

Recuperación de contraseña

+

Recibimos una solicitud para restablecer la contraseña de tu cuenta.

+

Hacé clic en el botón para crear una nueva contraseña. Este enlace es válido por 10 minutos.

+
+ + Restablecer contraseña + +
+

Si no solicitaste este cambio, ignorá este correo. Tu contraseña no será modificada.

+

O copiá este enlace en tu navegador:
${resetUrl}

+
+ `, + }); +}; diff --git a/src/modules/users/users/controllers/users.controllers.js b/src/modules/users/users/controllers/users.controllers.js index 4572a94c..9b6e92f0 100644 --- a/src/modules/users/users/controllers/users.controllers.js +++ b/src/modules/users/users/controllers/users.controllers.js @@ -3,7 +3,10 @@ import { createUserService, updateUserPasswordService, updateUserService, - getUserProfileService + getUserProfileService, + requestPasswordResetService, + validateResetTokenService, + resetPasswordService, } from "../services/users.services.js"; const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; @@ -120,4 +123,53 @@ export const getUserProfile = async (req,res) => { } -} \ No newline at end of file +} + +export const requestPasswordReset = async (req, res) => { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ success: false, message: "El email es requerido" }); + } + + await requestPasswordResetService(email); + + // Respuesta genérica para no revelar si el email existe en el sistema + return res.status(200).json({ + success: true, + message: "Si el correo está registrado, recibirás un enlace para restablecer tu contraseña.", + }); + } catch (error) { + const statusCode = error.statusCode || error.status || 500; + return res.status(statusCode).json({ success: false, message: error.message || "Error interno del servidor" }); + } +}; + +export const validateResetToken = async (req, res) => { + try { + const { token } = req.body; + const result = await validateResetTokenService(token); + return res.status(200).json({ success: true, ...result }); + } catch (error) { + const statusCode = error.statusCode || error.status || 500; + return res.status(statusCode).json({ success: false, message: error.message }); + } +}; + +export const resetPassword = async (req, res) => { + try { + const { token, newPassword } = req.body; + + if (!token || !newPassword) { + return res.status(400).json({ success: false, message: "Token y nueva contraseña son requeridos" }); + } + + await resetPasswordService(token, newPassword); + + return res.status(200).json({ success: true, message: "Contraseña restablecida exitosamente" }); + } catch (error) { + const statusCode = error.statusCode || error.status || 500; + return res.status(statusCode).json({ success: false, message: error.message || "Error interno del servidor" }); + } +}; \ No newline at end of file diff --git a/src/modules/users/users/routes/users.routes.js b/src/modules/users/users/routes/users.routes.js index 44da8476..2eb463e9 100644 --- a/src/modules/users/users/routes/users.routes.js +++ b/src/modules/users/users/routes/users.routes.js @@ -5,13 +5,21 @@ import { registerUser, updateUser, updateUserPassword, - getUserProfile + getUserProfile, + requestPasswordReset, + validateResetToken, + resetPassword, } from "../controllers/users.controllers.js"; const router = Router(); -// POST /api/users/register router.post("/register", registerUser); + +// Password reset (sin autenticación) +router.post("/forgot-password", requestPasswordReset); +router.post("/validate-reset-token", validateResetToken); +router.post("/reset-password", resetPassword); + router.put("/:id_user", authenticate, updateUser); router.put("/:id_user/password", authenticate, updateUserPassword); router.get("/:id_user", authenticate, getUserProfile); diff --git a/src/modules/users/users/services/users.services.js b/src/modules/users/users/services/users.services.js index 352fd9c4..77c408b3 100644 --- a/src/modules/users/users/services/users.services.js +++ b/src/modules/users/users/services/users.services.js @@ -2,6 +2,8 @@ //users.services.js import { prisma } from "../../../../lib/prisma.js"; import bcrypt from "bcrypt"; +import crypto from "crypto"; +import { sendPasswordResetEmail } from "../../../../lib/email.service.js"; //const prisma = new PrismaClient(); const SALT_ROUNDS = 10; @@ -407,4 +409,83 @@ const getAuthorizedUserForUpdateService = async ( } return user; // sin restricción de rol +}; + +const RESET_TOKEN_EXPIRY_MS = 10 * 60 * 1000; // 10 minutos + +export const requestPasswordResetService = async (email) => { + if (!email || !EMAIL_REGEX.test(email.trim())) { + throw { status: 400, message: "El formato del email no es válido" }; + } + + const user = await prisma.users.findUnique({ + where: { email: email.trim().toLowerCase() }, + select: { id_user: true, status: true }, + }); + + // No se retorna nada para no dar info de si el correo existe en la database + if (!user || !user.status) return; + + const token = crypto.randomBytes(32).toString("hex"); + const expires = new Date(Date.now() + RESET_TOKEN_EXPIRY_MS); + + await prisma.users.update({ + where: { id_user: user.id_user }, + data: { + password_reset_token: token, + password_reset_token_expires: expires, + }, + }); + + await sendPasswordResetEmail(email.trim().toLowerCase(), token); +}; + +export const validateResetTokenService = async (token) => { + if (!token) { + throw { status: 400, message: "Token requerido" }; + } + + const user = await prisma.users.findFirst({ + where: { + password_reset_token: token, + password_reset_token_expires: { gt: new Date() }, + status: true, + }, + select: { id_user: true }, + }); + + if (!user) { + throw { status: 400, message: "El enlace de recuperación no es válido o ha expirado" }; + } + + return { valid: true }; +}; + +export const resetPasswordService = async (token, newPassword) => { + if (!token) { + throw { status: 400, message: "Token requerido" }; + } + + if (!newPassword || newPassword.trim().length < 8) { + throw { status: 400, message: "La nueva contraseña debe tener al menos 8 caracteres" }; + } + + const password_hash = await bcrypt.hash(newPassword.trim(), SALT_ROUNDS); + + const result = await prisma.users.updateMany({ + where: { + password_reset_token: token, + password_reset_token_expires: { gt: new Date() }, + status: true, + }, + data: { + password_hash, + password_reset_token: null, + password_reset_token_expires: null, + }, + }); + + if (result.count === 0) { + throw { status: 400, message: "El enlace de recuperación no es válido o ha expirado" }; + } }; \ No newline at end of file diff --git a/tests/e2e/stores.test.js b/tests/e2e/stores.test.js index 0c675731..4d1f9102 100644 --- a/tests/e2e/stores.test.js +++ b/tests/e2e/stores.test.js @@ -33,7 +33,11 @@ vi.mock("../../src/lib/prisma.js", () => ({ findFirst: vi.fn(), update: vi.fn(), }, + storeBusinessHours: { + findMany: vi.fn().mockResolvedValue([]), + }, $transaction: vi.fn(), + $queryRawUnsafe: vi.fn().mockResolvedValue([]), }, })); @@ -89,7 +93,11 @@ const mockProducts = [ // ─── GET /api/commerces/:id ─────────────────────────────────────────────────── describe("GET /api/commerces/:id", () => { - beforeEach(() => vi.resetAllMocks()); + beforeEach(() => { + vi.resetAllMocks(); + prisma.$queryRawUnsafe.mockResolvedValue([]); + prisma.storeBusinessHours.findMany.mockResolvedValue([]); + }); it("devuelve 200 con datos del comercio cuando existe", async () => { prisma.stores.findUnique.mockResolvedValue(mockStore); diff --git a/tests/unit/commerce/store-status.test.js b/tests/unit/commerce/store-status.test.js index e5666e3b..4584c5b2 100644 --- a/tests/unit/commerce/store-status.test.js +++ b/tests/unit/commerce/store-status.test.js @@ -13,7 +13,11 @@ vi.mock("../../../src/lib/prisma.js", () => ({ products: { updateMany: vi.fn(), }, + storeBusinessHours: { + findMany: vi.fn().mockResolvedValue([]), + }, $transaction: vi.fn(), + $queryRawUnsafe: vi.fn().mockResolvedValue([]), } })); diff --git a/tests/unit/users/password-reset.test.js b/tests/unit/users/password-reset.test.js new file mode 100644 index 00000000..532697ee --- /dev/null +++ b/tests/unit/users/password-reset.test.js @@ -0,0 +1,233 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { prisma } from "../../../src/lib/prisma.js"; +import bcrypt from "bcrypt"; +import crypto from "crypto"; +import { sendPasswordResetEmail } from "../../../src/lib/email.service.js"; +import { + requestPasswordResetService, + validateResetTokenService, + resetPasswordService, +} from "../../../src/modules/users/users/services/users.services.js"; + +vi.mock("../../../src/lib/prisma.js", () => ({ + prisma: { + users: { + findUnique: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + }, +})); + +vi.mock("bcrypt", () => ({ + default: { + hash: vi.fn(), + compare: vi.fn(), + }, +})); + +vi.mock("crypto", () => ({ + default: { + randomBytes: vi.fn(() => ({ toString: () => "mockedtoken123abc" })), + }, +})); + +vi.mock("../../../src/lib/email.service.js", () => ({ + sendPasswordResetEmail: vi.fn(), +})); + +const mockActiveUser = { id_user: 5, status: true }; +const mockInactiveUser = { id_user: 5, status: false }; +const VALID_TOKEN = "validtoken123abc"; +const FUTURE_DATE = new Date(Date.now() + 60 * 60 * 1000); + +// ─── requestPasswordResetService ──────────────────────────────────────────── + +describe("requestPasswordResetService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza 400 cuando el email no tiene formato válido", async () => { + await expect(requestPasswordResetService("no-es-un-email")).rejects.toMatchObject({ + status: 400, + message: expect.stringContaining("formato"), + }); + }); + + it("lanza 400 cuando el email está vacío", async () => { + await expect(requestPasswordResetService("")).rejects.toMatchObject({ + status: 400, + }); + }); + + it("retorna sin error y sin enviar email cuando el usuario no existe", async () => { + prisma.users.findUnique.mockResolvedValue(null); + + await expect(requestPasswordResetService("noexiste@test.com")).resolves.toBeUndefined(); + expect(sendPasswordResetEmail).not.toHaveBeenCalled(); + }); + + it("retorna sin error y sin enviar email cuando el usuario está inactivo", async () => { + prisma.users.findUnique.mockResolvedValue(mockInactiveUser); + + await expect(requestPasswordResetService("inactivo@test.com")).resolves.toBeUndefined(); + expect(sendPasswordResetEmail).not.toHaveBeenCalled(); + }); + + it("genera token, actualiza la BD y envía el email cuando el usuario existe", async () => { + prisma.users.findUnique.mockResolvedValue(mockActiveUser); + prisma.users.update.mockResolvedValue({}); + sendPasswordResetEmail.mockResolvedValue(undefined); + + await requestPasswordResetService("usuario@test.com"); + + expect(prisma.users.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id_user: mockActiveUser.id_user }, + data: expect.objectContaining({ + password_reset_token: "mockedtoken123abc", + password_reset_token_expires: expect.any(Date), + }), + }) + ); + expect(sendPasswordResetEmail).toHaveBeenCalledWith("usuario@test.com", "mockedtoken123abc"); + }); + + it("normaliza el email a minúsculas antes de buscar", async () => { + prisma.users.findUnique.mockResolvedValue(null); + + await requestPasswordResetService("USUARIO@TEST.COM"); + + expect(prisma.users.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { email: "usuario@test.com" } }) + ); + }); +}); + +// ─── validateResetTokenService ─────────────────────────────────────────────── + +describe("validateResetTokenService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza 400 cuando no se provee token", async () => { + await expect(validateResetTokenService(undefined)).rejects.toMatchObject({ + status: 400, + message: "Token requerido", + }); + }); + + it("lanza 400 cuando el token no existe en la BD", async () => { + prisma.users.findFirst.mockResolvedValue(null); + + await expect(validateResetTokenService("tokeninexistente")).rejects.toMatchObject({ + status: 400, + message: expect.stringContaining("no es válido"), + }); + }); + + it("lanza 400 cuando el token existe pero ya expiró", async () => { + // findFirst con gt: new Date() no encontrará el token expirado → retorna null + prisma.users.findFirst.mockResolvedValue(null); + + await expect(validateResetTokenService("tokenexpirado")).rejects.toMatchObject({ + status: 400, + message: expect.stringContaining("expirado"), + }); + }); + + it("resuelve sin error cuando el token es válido y no expiró", async () => { + prisma.users.findFirst.mockResolvedValue(mockActiveUser); + + await expect(validateResetTokenService(VALID_TOKEN)).resolves.toEqual({ valid: true }); + }); + + it("busca el token con filtro de expiración mayor a la fecha actual", async () => { + prisma.users.findFirst.mockResolvedValue(mockActiveUser); + + await validateResetTokenService(VALID_TOKEN); + + expect(prisma.users.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + password_reset_token: VALID_TOKEN, + password_reset_token_expires: { gt: expect.any(Date) }, + status: true, + }), + }) + ); + }); +}); + +// ─── resetPasswordService ──────────────────────────────────────────────────── + +describe("resetPasswordService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza 400 cuando no se provee token", async () => { + await expect(resetPasswordService(undefined, "nuevaPass1")).rejects.toMatchObject({ + status: 400, + message: "Token requerido", + }); + }); + + it("lanza 400 cuando no se provee nueva contraseña", async () => { + await expect(resetPasswordService(VALID_TOKEN, undefined)).rejects.toMatchObject({ + status: 400, + }); + }); + + it("lanza 400 cuando la contraseña tiene menos de 8 caracteres", async () => { + await expect(resetPasswordService(VALID_TOKEN, "corta")).rejects.toMatchObject({ + status: 400, + message: expect.stringContaining("8 caracteres"), + }); + }); + + it("lanza 400 cuando el token no existe o ya expiró", async () => { + prisma.users.updateMany.mockResolvedValue({ count: 0 }); + bcrypt.hash.mockResolvedValue("hash"); + + await expect(resetPasswordService("tokeninvalido", "nuevaPass123")).rejects.toMatchObject({ + status: 400, + message: expect.stringContaining("no es válido"), + }); + }); + + it("actualiza el hash de contraseña y limpia el token en una sola operación atómica", async () => { + bcrypt.hash.mockResolvedValue("nuevo_hash_hasheado"); + prisma.users.updateMany.mockResolvedValue({ count: 1 }); + + await resetPasswordService(VALID_TOKEN, "nuevaPassword123"); + + expect(bcrypt.hash).toHaveBeenCalledWith("nuevaPassword123", 10); + expect(prisma.users.updateMany).toHaveBeenCalledWith({ + where: { + password_reset_token: VALID_TOKEN, + password_reset_token_expires: { gt: expect.any(Date) }, + status: true, + }, + data: { + password_hash: "nuevo_hash_hasheado", + password_reset_token: null, + password_reset_token_expires: null, + }, + }); + }); + + it("elimina los espacios al inicio y fin de la contraseña antes de hashear", async () => { + bcrypt.hash.mockResolvedValue("hash"); + prisma.users.updateMany.mockResolvedValue({ count: 1 }); + + await resetPasswordService(VALID_TOKEN, " nuevaPass123 "); + + expect(bcrypt.hash).toHaveBeenCalledWith("nuevaPass123", 10); + }); + + it("no actualiza la BD si el token es inválido", async () => { + bcrypt.hash.mockResolvedValue("hash"); + prisma.users.updateMany.mockResolvedValue({ count: 0 }); + + await expect(resetPasswordService("invalido", "nuevaPass123")).rejects.toThrow(); + expect(prisma.users.updateMany).toHaveBeenCalledTimes(1); + }); +});