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.
+
+
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);
+ });
+});