diff --git a/.github/workflows/vitest-supertest.yml b/.github/workflows/vitest-supertest.yml index 6b7c0244..2fb39e6a 100644 --- a/.github/workflows/vitest-supertest.yml +++ b/.github/workflows/vitest-supertest.yml @@ -13,6 +13,25 @@ on: branches: [ dev, main ] jobs: + security-audit: + name: Security audit (high/critical) + timeout-minutes: 10 + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Run npm audit policy + run: npx --yes audit-ci@7 --config security/audit-ci.json + test: timeout-minutes: 20 runs-on: ubuntu-latest diff --git a/security/AUDIT_EXCEPTIONS.md b/security/AUDIT_EXCEPTIONS.md new file mode 100644 index 00000000..8d7c8102 --- /dev/null +++ b/security/AUDIT_EXCEPTIONS.md @@ -0,0 +1,16 @@ +# Excepciones de npm audit + +Este archivo documenta las excepciones permitidas para el control de seguridad en CI. + +- Política activa: el pipeline falla con vulnerabilidades `high` o `critical`. +- Estado actual: no hay excepciones aprobadas. + +Si en el futuro se requiere una excepción, debe: + +1. Agregarse en `security/audit-ci.json` dentro de `allowlist`. +2. Documentarse aquí con: + - advisory/module afectado + - justificación + - fecha de aprobación + - fecha de expiración + - responsable diff --git a/security/audit-ci.json b/security/audit-ci.json new file mode 100644 index 00000000..10931897 --- /dev/null +++ b/security/audit-ci.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://raw.githubusercontent.com/IBM/audit-ci/main/docs/schema.json", + "high": true, + "allowlist": [] +} diff --git a/src/app.js b/src/app.js index f5be2302..c887a652 100644 --- a/src/app.js +++ b/src/app.js @@ -34,6 +34,7 @@ import cartRoutes from "./modules/users/cart/cart.routes.js"; import { orderRouter, userOrderRouter } from "./modules/users/orders/order.routes.js"; import { errorHandler } from "./middlewares/errorHandler.js"; +import { securityResponseLogger } from "./middlewares/security-log.middleware.js"; import { NotFoundError } from "./lib/errors.js"; import { setupSwagger } from "./config/swagger.config.js"; @@ -76,6 +77,7 @@ app.use((req, res, next) => { app.use(morgan('dev')); app.use(express.json()); app.use(cookieParser()); +app.use(securityResponseLogger); const allowedOrigins = process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',').map(o => o.trim()) diff --git a/src/lib/security-logger.js b/src/lib/security-logger.js new file mode 100644 index 00000000..7a718e96 --- /dev/null +++ b/src/lib/security-logger.js @@ -0,0 +1,147 @@ +const SENSITIVE_KEY_NAMES = [ + "password", + "password_hash", + "token", + "user_token", + "authorization", + "cookie", + "cookies", + "secret", + "jwt", + "refresh_token", + "access_token", +]; + +const JWT_PATTERN = /eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*/g; +const BEARER_PATTERN = /Bearer\s+\S+/gi; +const CONTROL_CHARS_PATTERN = /[\u0000-\u001F\u007F]/g; +const MAX_LOG_LINE_LENGTH = 8192; + +/** + * Normaliza nombres de clave (camelCase, kebab-case) a snake_case en minúsculas. + */ +export function normalizeKey(key) { + if (typeof key !== "string") return ""; + return key + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/-/g, "_") + .toLowerCase(); +} + +const SENSITIVE_KEYS = new Set(SENSITIVE_KEY_NAMES.map(normalizeKey)); + +function isSensitiveKey(key) { + return SENSITIVE_KEYS.has(normalizeKey(key)); +} + +function sanitizeLogString(str) { + return str + .replace(CONTROL_CHARS_PATTERN, "") + .replace(JWT_PATTERN, "[REDACTED_JWT]") + .replace(BEARER_PATTERN, "Bearer [REDACTED]"); +} + +/** + * Elimina datos sensibles antes de escribir en logs (tokens, passwords, etc.). + */ +export function sanitizeForLog(value, depth = 0) { + if (depth > 6) return "[MaxDepth]"; + if (value == null) return value; + + if (typeof value === "string") { + return sanitizeLogString(value); + } + + if (typeof value === "number" || typeof value === "boolean") { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => sanitizeForLog(item, depth + 1)); + } + + if (typeof value === "object") { + const sanitized = {}; + for (const [key, val] of Object.entries(value)) { + if (isSensitiveKey(key)) { + sanitized[key] = "[REDACTED]"; + } else { + sanitized[key] = sanitizeForLog(val, depth + 1); + } + } + return sanitized; + } + + return String(value); +} + +function stripReservedDetailKeys(details) { + if (details == null || typeof details !== "object" || Array.isArray(details)) { + return {}; + } + + const safe = { ...details }; + delete safe.event; + delete safe.timestamp; + return safe; +} + +/** + * Construye la línea JSON del evento tras sanitizar y validar el contenido. + */ +export function buildSecurityLogLine(eventType, details = {}) { + const payload = sanitizeForLog({ + ...stripReservedDetailKeys(details), + timestamp: new Date().toISOString(), + event: eventType, + }); + + return JSON.stringify(payload); +} + +function isValidSecurityLogLine(line) { + if (typeof line !== "string" || line.length === 0 || line.length > MAX_LOG_LINE_LENGTH) { + return false; + } + + if (CONTROL_CHARS_PATTERN.test(line)) { + return false; + } + + try { + JSON.parse(line); + return true; + } catch { + return false; + } +} + +function writeSecurityLogLine(line) { + process.stdout.write(`[SECURITY] ${line}\n`); +} + +/** + * Registra un evento de seguridad en stdout (JSON). Nunca incluir passwords ni tokens. + * No lanza errores: fallos de logging no afectan el flujo de negocio. + */ +export function logSecurityEvent(eventType, details = {}) { + try { + if (typeof eventType !== "string" || eventType.length === 0) { + return; + } + + const line = buildSecurityLogLine(eventType, details); + if (!isValidSecurityLogLine(line)) { + console.error("[SECURITY] Discarded invalid log line for event:", eventType); + return; + } + + writeSecurityLogLine(line); + } catch (err) { + console.error( + "[SECURITY] Failed to log security event:", + eventType, + err?.message ?? err + ); + } +} diff --git a/src/middlewares/security-log.middleware.js b/src/middlewares/security-log.middleware.js new file mode 100644 index 00000000..690a8472 --- /dev/null +++ b/src/middlewares/security-log.middleware.js @@ -0,0 +1,23 @@ +import { logSecurityEvent } from "../lib/security-logger.js"; + +/** + * Registra respuestas HTTP 403 al finalizar la petición (cubre AppError y errores legacy en controllers). + */ +export const securityResponseLogger = (req, res, next) => { + res.on("finish", () => { + if (res.statusCode !== 403) return; + + try { + logSecurityEvent("FORBIDDEN", { + method: req.method, + path: req.originalUrl || req.path, + userId: req.user?.id_user ?? null, + role: req.user?.role ?? null, + }); + } catch (err) { + console.error("[SECURITY] Failed to log FORBIDDEN event:", err?.message ?? err); + } + }); + + next(); +}; diff --git a/src/middlewares/validate.middleware.js b/src/middlewares/validate.middleware.js index 74453dd3..23749996 100644 --- a/src/middlewares/validate.middleware.js +++ b/src/middlewares/validate.middleware.js @@ -20,7 +20,9 @@ export const validate = } if (section === "query") { - req.validated = result.data; + req.validated = { ...(req.validated ?? {}), ...result.data }; + } else if (section === "params") { + req.params = { ...req.params, ...result.data }; } else { req[section] = result.data; } diff --git a/src/modules/delivery/delivery-assignments/delivery-assignments.service.js b/src/modules/delivery/delivery-assignments/delivery-assignments.service.js index d9442c23..dff49ca4 100644 --- a/src/modules/delivery/delivery-assignments/delivery-assignments.service.js +++ b/src/modules/delivery/delivery-assignments/delivery-assignments.service.js @@ -1,5 +1,6 @@ //delivery-assignments.service.js import { prisma } from '../../../lib/prisma.js'; +import { logSecurityEvent } from '../../../lib/security-logger.js'; import { activePendingAssignmentWhere, closePendingAndReassign, @@ -12,6 +13,7 @@ import { export const createAssignmentService = async (data) => { const { fk_order, fk_delivery: fk_delivery_input, status } = data; + const pendingAuditLogs = []; const order = await prisma.orders.findUnique({ where: { id_order: fk_order } @@ -44,10 +46,32 @@ export const createAssignmentService = async (data) => { where: { id_delivery_assignment: pendingAssignment.id_delivery_assignment }, data: { assignment_status: "REJECTED", status: false } }); + pendingAuditLogs.push({ + event: "ASSIGNMENT_STATUS_CHANGED", + details: { + assignmentId: pendingAssignment.id_delivery_assignment, + orderId: fk_order, + deliveryId: pendingAssignment.fk_delivery, + previousStatus: pendingAssignment.assignment_status, + newStatus: "REJECTED", + reason: "offline", + } + }); } else if (isAssignmentResponseWindowOpen(pendingAssignment)) { throw { status: 409, message: "Ya hay una asignación pendiente para este pedido" }; } else { + const previousStatus = pendingAssignment.assignment_status; await closePendingAndReassign(tx, pendingAssignment, "EXPIRED"); + pendingAuditLogs.push({ + event: "ASSIGNMENT_STATUS_CHANGED", + details: { + assignmentId: pendingAssignment.id_delivery_assignment, + orderId: fk_order, + deliveryId: pendingAssignment.fk_delivery, + previousStatus, + newStatus: "EXPIRED", + } + }); const stillPending = await tx.deliveryAssignments.findFirst({ where: { fk_order, ...activePendingAssignmentWhere() } }); @@ -90,6 +114,21 @@ export const createAssignmentService = async (data) => { }); }); + pendingAuditLogs.push({ + event: "ASSIGNMENT_STATUS_CHANGED", + details: { + assignmentId: newDeliveryAssignment.id_delivery_assignment, + orderId: fk_order, + deliveryId: newDeliveryAssignment.fk_delivery, + previousStatus: null, + newStatus: newDeliveryAssignment.assignment_status ?? "PENDING", + } + }); + + for (const { event, details } of pendingAuditLogs) { + logSecurityEvent(event, details); + } + return newDeliveryAssignment; } catch (error) { if (error?.code === 'P2002') { @@ -272,7 +311,14 @@ export const completeAssignmentService = async (id_delivery_assignment, id_user) throw { status: 403, message: "No tienes permiso para completar esta asignación" }; } + let previousOrderStatus = null; const updated = await prisma.$transaction(async (tx) => { + const order = await tx.orders.findUnique({ + where: { id_order: assignment.fk_order }, + select: { order_status: true } + }); + previousOrderStatus = order?.order_status ?? null; + const updatedAssignment = await tx.deliveryAssignments.update({ where: { id_delivery_assignment }, data: { assignment_status: "DELIVERED" } @@ -286,6 +332,24 @@ export const completeAssignmentService = async (id_delivery_assignment, id_user) return updatedAssignment; }); + logSecurityEvent("ASSIGNMENT_STATUS_CHANGED", { + assignmentId: id_delivery_assignment, + orderId: assignment.fk_order, + deliveryId: assignment.fk_delivery, + previousStatus: "ACCEPTED", + newStatus: "DELIVERED", + actorUserId: id_user, + }); + + logSecurityEvent("ORDER_STATUS_CHANGED", { + orderId: assignment.fk_order, + previousStatus: previousOrderStatus, + newStatus: "DELIVERED", + actorUserId: id_user, + actorRole: "DELIVERY", + source: "assignment_complete", + }); + return updated; }; @@ -295,7 +359,9 @@ export const respondToAssignmentService = async (orderId, userId, action) => { throw { status: 400, message: "ID de orden inválido" }; } - return prisma.$transaction(async (tx) => { + const pendingAuditLogs = []; + + const result = await prisma.$transaction(async (tx) => { await expireStalePendingAssignments({ fk_order: parsedOrderId }, tx); const assignment = await tx.deliveryAssignments.findFirst({ @@ -334,12 +400,51 @@ export const respondToAssignmentService = async (orderId, userId, action) => { data: { order_status: "SHIPPED", delivery_unavailable: false } }); + pendingAuditLogs.push( + { + event: "ASSIGNMENT_STATUS_CHANGED", + details: { + assignmentId: assignment.id_delivery_assignment, + orderId: parsedOrderId, + deliveryId: assignment.fk_delivery, + previousStatus: "PENDING", + newStatus: "ACCEPTED", + actorUserId: userId, + }, + }, + { + event: "ORDER_STATUS_CHANGED", + details: { + orderId: parsedOrderId, + previousStatus: assignment.order?.order_status ?? null, + newStatus: "SHIPPED", + actorUserId: userId, + actorRole: "DELIVERY", + source: "assignment_accept", + }, + } + ); + return updated; } const outcome = await closePendingAndReassign(tx, assignment, "REJECTED", { reason: "reject" }); if (outcome.reassigned) { + pendingAuditLogs.push({ + event: "ASSIGNMENT_STATUS_CHANGED", + details: { + assignmentId: assignment.id_delivery_assignment, + orderId: parsedOrderId, + deliveryId: assignment.fk_delivery, + previousStatus: "PENDING", + newStatus: "REJECTED", + actorUserId: userId, + reassigned: true, + newAssignmentId: outcome.assignment?.id_delivery_assignment ?? null, + }, + }); + return { ...outcome.assignment, reassigned: true, @@ -347,6 +452,19 @@ export const respondToAssignmentService = async (orderId, userId, action) => { }; } + pendingAuditLogs.push({ + event: "ASSIGNMENT_STATUS_CHANGED", + details: { + assignmentId: assignment.id_delivery_assignment, + orderId: parsedOrderId, + deliveryId: assignment.fk_delivery, + previousStatus: "PENDING", + newStatus: "REJECTED", + actorUserId: userId, + reassigned: false, + }, + }); + return { assignment_status: "REJECTED", fk_order: parsedOrderId, @@ -354,6 +472,12 @@ export const respondToAssignmentService = async (orderId, userId, action) => { delivery_unavailable: true, }; }); + + for (const { event, details } of pendingAuditLogs) { + logSecurityEvent(event, details); + } + + return result; }; export const getDeliveryOrderHistoryService = async (deliveryId, authenticatedUserId, filters, pagination) => { diff --git a/src/modules/session/controllers/session.controllers.js b/src/modules/session/controllers/session.controllers.js index 6b1879ad..b2db671a 100644 --- a/src/modules/session/controllers/session.controllers.js +++ b/src/modules/session/controllers/session.controllers.js @@ -1,14 +1,14 @@ import jwt from "jsonwebtoken"; import dotenv from "dotenv"; -import { - prisma, - hashPassword, - verifyPassword, - UnauthorizedError, - ValidationError, - addToBlacklist, - cleanExpiredTokens +import { + prisma, + verifyPassword, + UnauthorizedError, + ValidationError, + addToBlacklist, + cleanExpiredTokens, } from "../../../lib/index.js"; +import { logSecurityEvent } from "../../../lib/security-logger.js"; dotenv.config(); @@ -29,6 +29,10 @@ export const login = async (req, res, next) => { const { email, password } = req.body; if (!email || !password) { + logSecurityEvent("LOGIN_FAILED", { + reason: "missing_credentials", + email: email ?? null, + }); return next(new ValidationError("Debe ingresar email y contraseña")); } @@ -40,6 +44,10 @@ export const login = async (req, res, next) => { }); if (!user) { + logSecurityEvent("LOGIN_FAILED", { + reason: "invalid_credentials", + email, + }); return next(new UnauthorizedError("Credenciales inválidas")); } @@ -51,6 +59,10 @@ export const login = async (req, res, next) => { } if (!passwordMatch) { + logSecurityEvent("LOGIN_FAILED", { + reason: "invalid_credentials", + email, + }); return next(new UnauthorizedError("Credenciales inválidas")); } @@ -195,4 +207,4 @@ export const userSession = async (req, res, next) => { } catch (error) { next(error); } -}; \ No newline at end of file +}; diff --git a/src/modules/users/orders/order.service.js b/src/modules/users/orders/order.service.js index 5b09af7e..a50f6c84 100644 --- a/src/modules/users/orders/order.service.js +++ b/src/modules/users/orders/order.service.js @@ -1,5 +1,6 @@ //order.service.js import { prisma } from "../../../lib/prisma.js"; +import { logSecurityEvent } from "../../../lib/security-logger.js"; import { ValidationError, ForbiddenError, @@ -917,5 +918,13 @@ export const updateOrderStatusService = async (authenticatedUserId, orderId, ord return updatedOrder; }); + logSecurityEvent("ORDER_STATUS_CHANGED", { + orderId: resolvedOrderId, + previousStatus: order.order_status, + newStatus: order_status, + actorUserId: resolvedUserId, + actorRole: user.role, + }); + return mapOrderResponse(updated, prisma); }; \ No newline at end of file diff --git a/tests/e2e/assignments-complete.test.js b/tests/e2e/assignments-complete.test.js index a457d3e7..7fc0de8d 100644 --- a/tests/e2e/assignments-complete.test.js +++ b/tests/e2e/assignments-complete.test.js @@ -8,6 +8,7 @@ const mockTx = { update: vi.fn(), }, orders: { + findUnique: vi.fn(), update: vi.fn(), }, }; @@ -70,6 +71,7 @@ describe("POST /api/assignments/:id/complete", () => { vi.clearAllMocks(); mockTx.deliveryAssignments.update.mockReset(); + mockTx.orders.findUnique.mockReset(); mockTx.orders.update.mockReset(); prisma.deliveryAssignments.findUnique.mockReset(); @@ -79,6 +81,7 @@ describe("POST /api/assignments/:id/complete", () => { it("retrona 200 tras completar asignación con exito", async () => { prisma.deliveryAssignments.findUnique.mockResolvedValue(mockAcceptedAssignment); + mockTx.orders.findUnique.mockResolvedValue({ order_status: "SHIPPED" }); mockTx.deliveryAssignments.update.mockResolvedValue(mockDeliveredAssignment); mockTx.orders.update.mockResolvedValue({ id_order: 100, order_status: "DELIVERED" }); diff --git a/tests/e2e/delivery-profile.test.js b/tests/e2e/delivery-profile.test.js index 6a4c7f33..8062bbd9 100644 --- a/tests/e2e/delivery-profile.test.js +++ b/tests/e2e/delivery-profile.test.js @@ -153,18 +153,14 @@ describe("PUT /api/deliveries/:id — E2E profile", () => { expect(res.body).toHaveProperty("vehicle_type", "BICYCLE"); }); - it("Body vacío no ejecuta updates y retorna 200", async () => { - prisma.deliveries.findUnique - .mockResolvedValueOnce(mockDelivery) - .mockResolvedValueOnce(mockUpdatedDeliveryBase()); - + it("Body vacío retorna 400 por validación de DTO", async () => { const res = await request(app) .put("/api/deliveries/1") .set("Cookie", authCookie(deliveryToken)) .send({}); - expect(res.status).toBe(200); - // $transaction should not call updates for empty body; users.update and deliveries.update remain mocked but may not be called + expect(res.status).toBe(400); + expect(res.body.message).toBe("Error de validación"); expect(prisma.users.update).not.toHaveBeenCalled(); expect(prisma.deliveries.update).not.toHaveBeenCalled(); }); diff --git a/tests/e2e/delivery-register.test.js b/tests/e2e/delivery-register.test.js index d80adc86..63014867 100644 --- a/tests/e2e/delivery-register.test.js +++ b/tests/e2e/delivery-register.test.js @@ -41,8 +41,12 @@ describe("POST /api/deliveries/register", () => { .send({}); expect(res.status).toBe(400); - expect(res.body.error.code).toBe(400); - expect(res.body.error.message).toMatch(/datos inválidos/i); + expect(res.body.message).toBe("Error de validación"); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "vehicleType" }), + ]) + ); }); it("retorna 400 cuando vehicleType es inválido", async () => { @@ -52,8 +56,12 @@ describe("POST /api/deliveries/register", () => { .send({ vehicleType: "PLANE" }); expect(res.status).toBe(400); - expect(res.body.error.code).toBe(400); - expect(res.body.error.message).toMatch(/datos inválidos/i); + expect(res.body.message).toBe("Error de validación"); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "vehicleType" }), + ]) + ); }); it("retorna 404 cuando el usuario no existe", async () => { @@ -105,6 +113,7 @@ describe("POST /api/deliveries/register", () => { id_user: 10, role: "CUSTOMER", delivery: null, + phone: "0981234567", }); prisma.users.update.mockResolvedValue({ @@ -137,7 +146,10 @@ describe("POST /api/deliveries/register", () => { expect(prisma.users.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id_user: 10 }, - data: { role: "DELIVERY" }, + data: expect.objectContaining({ + role: "DELIVERY", + phone: "0981234567", + }), }) ); expect(prisma.deliveries.create).toHaveBeenCalledWith( diff --git a/tests/e2e/delivery-status.test.js b/tests/e2e/delivery-status.test.js index 93b78ec7..f2e5daca 100644 --- a/tests/e2e/delivery-status.test.js +++ b/tests/e2e/delivery-status.test.js @@ -109,7 +109,12 @@ describe("PATCH /api/deliveries/:id/status", () => { .send({}); expect(res.status).toBe(400); - expect(res.body.error.message).toMatch(/delivery_status es requerido/i); + expect(res.body.message).toBe("Error de validación"); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "delivery_status" }), + ]) + ); }); it("retorna 400 cuando delivery_status es inválido", async () => { @@ -119,7 +124,12 @@ describe("PATCH /api/deliveries/:id/status", () => { .send({ delivery_status: "PAUSED" }); expect(res.status).toBe(400); - expect(res.body.error.message).toMatch(/ACTIVE o INACTIVE/i); + expect(res.body.message).toBe("Error de validación"); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "delivery_status" }), + ]) + ); }); it("retorna 403 cuando el rol no es DELIVERY", async () => { @@ -139,7 +149,12 @@ describe("PATCH /api/deliveries/:id/status", () => { .send({ delivery_status: "ACTIVE" }); expect(res.status).toBe(400); - expect(res.body.error.message).toMatch(/ID inválido/i); + expect(res.body.message).toBe("Error de validación"); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "id" }), + ]) + ); }); it("retorna 404 cuando el delivery no existe", async () => { diff --git a/tests/e2e/delivery.test.js b/tests/e2e/delivery.test.js index 9e4a9f93..834c0a41 100644 --- a/tests/e2e/delivery.test.js +++ b/tests/e2e/delivery.test.js @@ -232,7 +232,12 @@ describe("POST /api/stores/{id}/deliveries", () => { .send({}); expect(res.status).toBe(400); - expect(res.body.message).toMatch(/fk_user es requerido/i); + expect(res.body.message).toBe("Error de validación"); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "fk_user" }), + ]) + ); }); it("Retorna 403 cuando el usuario no es dueño del store", async () => { @@ -443,7 +448,12 @@ describe("POST /api/stores/{id}/deliveries", () => { .send({ fk_user: -5 }); expect(res.status).toBe(400); - expect(res.body.message).toMatch(/id numérico válido/i); + expect(res.body.message).toBe("Error de validación"); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "fk_user" }), + ]) + ); }); it("Validación de fk_user no acepta strings", async () => { @@ -455,7 +465,12 @@ describe("POST /api/stores/{id}/deliveries", () => { .send({ fk_user: "abc" }); expect(res.status).toBe(400); - expect(res.body.message).toMatch(/id numérico válido/i); + expect(res.body.message).toBe("Error de validación"); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "fk_user" }), + ]) + ); }); it("Retorna 401 cuando no hay autenticación", async () => { diff --git a/tests/e2e/notifications.test.js b/tests/e2e/notifications.test.js index bac9df01..08d996d5 100644 --- a/tests/e2e/notifications.test.js +++ b/tests/e2e/notifications.test.js @@ -4,7 +4,7 @@ vi.mock("../../src/lib/prisma.js", () => ({ prisma: { users: { findUnique: vi.fn() }, carts: { findFirst: vi.fn(), update: vi.fn() }, - orders: { create: vi.fn(), findUnique: vi.fn() }, + orders: { create: vi.fn(), findUnique: vi.fn(), count: vi.fn() }, orderItems: { createMany: vi.fn() }, products: { update: vi.fn() }, notifications: { create: vi.fn() }, @@ -41,6 +41,7 @@ const customerCookie = "userToken=customer-token"; describe("POST /api/orders -> crea notificación", () => { beforeEach(() => { vi.clearAllMocks(); + prisma.orders.count.mockResolvedValue(0); }); it("retorna 201 y crea notificación en la transacción al confirmar pedido", async () => { diff --git a/tests/e2e/orders-delivery-reviews.test.js b/tests/e2e/orders-delivery-reviews.test.js index 30faa1e9..88005214 100644 --- a/tests/e2e/orders-delivery-reviews.test.js +++ b/tests/e2e/orders-delivery-reviews.test.js @@ -175,8 +175,12 @@ describe("GET /api/orders/pending-delivery-reviews & POST /api/orders/:orderId/d .send({ rating: 0, comment: "Buen servicio" }) .expect(400); - expect(res.body.error.code).toBe(400); - expect(res.body.error.message).toMatch(/entre 1 y 5/i); + expect(res.body.message).toBe("Error de validación"); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "rating" }), + ]) + ); }); it("retorna 400 cuando rating es mayor a cinco", async () => { @@ -186,8 +190,12 @@ describe("GET /api/orders/pending-delivery-reviews & POST /api/orders/:orderId/d .send({ rating: 6, comment: "Buen servicio" }) .expect(400); - expect(res.body.error.code).toBe(400); - expect(res.body.error.message).toMatch(/entre 1 y 5/i); + expect(res.body.message).toBe("Error de validación"); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "rating" }), + ]) + ); }); it("retorna 400 cuando comentario excede 1000 caracteres", async () => { @@ -199,8 +207,12 @@ describe("GET /api/orders/pending-delivery-reviews & POST /api/orders/:orderId/d .send({ rating: 5, comment: longComment }) .expect(400); - expect(res.body.error.code).toBe(400); - expect(res.body.error.message).toMatch(/no puede superar los 1000/i); + expect(res.body.message).toBe("Error de validación"); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "comment" }), + ]) + ); }); it("retorna 409 cuando el pedido ya tiene una calificación", async () => { diff --git a/tests/e2e/product/products.test.js b/tests/e2e/product/products.test.js index 17310280..0c3caf10 100644 --- a/tests/e2e/product/products.test.js +++ b/tests/e2e/product/products.test.js @@ -253,6 +253,16 @@ describe("POST /products", () => { prisma.categories.findMany.mockResolvedValue([{ id_category: 1 }]); }); + const expectFieldValidationError = (res, field) => { + expect(res.status).toBe(400); + expect(res.body.message).toBe("Error de validación"); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field }), + ]) + ); + }; + it("devuelve 401 cuando falta autenticacion", async () => { const res = await request(app) .post("/products") @@ -268,8 +278,7 @@ describe("POST /products", () => { .set("Cookie", `userToken=${sellerToken}`) .send({ name: "", price: 10, categoryId: 1 }); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/name/i); + expectFieldValidationError(res, "name"); }); it("devuelve 400 cuando falta price", async () => { @@ -278,8 +287,7 @@ describe("POST /products", () => { .set("Cookie", `userToken=${sellerToken}`) .send({ name: "Test", categoryId: 1 }); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/price/i); + expectFieldValidationError(res, "price"); }); it("devuelve 400 cuando price es 0 o negativo", async () => { @@ -288,8 +296,7 @@ describe("POST /products", () => { .set("Cookie", `userToken=${sellerToken}`) .send({ name: "Test", price: -5, categoryId: 1 }); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/price/i); + expectFieldValidationError(res, "price"); }); it("devuelve 400 cuando falta categoryId", async () => { @@ -298,8 +305,7 @@ describe("POST /products", () => { .set("Cookie", `userToken=${sellerToken}`) .send({ name: "Test", price: 10 }); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/categor/i); + expectFieldValidationError(res, "categoryId"); }); it("devuelve 400 cuando visible tiene valor invalido", async () => { @@ -308,18 +314,16 @@ describe("POST /products", () => { .set("Cookie", `userToken=${sellerToken}`) .send({ name: "Test", price: 10, categoryId: 1, quantity: 5, visible: "invalido" }); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/visible/i); + expectFieldValidationError(res, "visible"); }); it("devuelve 400 cuando tags no es un array", async () => { const res = await request(app) .post("/products") .set("Cookie", `userToken=${sellerToken}`) - .send({ name: "Test", price: 10, categoryId: 1, quantity: 5, tags: "no-array" }); + .send({ name: "Test", price: 10, categoryId: 1, quantity: 5, tags: 123 }); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/tags/i); + expectFieldValidationError(res, "tags"); }); it("devuelve 201 con el producto creado cuando los datos son validos", async () => { diff --git a/tests/e2e/stock.test.js b/tests/e2e/stock.test.js index 336fb341..2eaaf508 100644 --- a/tests/e2e/stock.test.js +++ b/tests/e2e/stock.test.js @@ -12,7 +12,7 @@ vi.mock("../../src/lib/prisma.js", () => ({ }, carts: { findFirst: vi.fn(), upsert: vi.fn(), update: vi.fn(), findUnique: vi.fn(), findMany: vi.fn() }, cartItems: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), findMany: vi.fn() }, - orders: { create: vi.fn(), findUnique: vi.fn() }, + orders: { create: vi.fn(), findUnique: vi.fn(), count: vi.fn() }, orderItems: { createMany: vi.fn(), findMany: vi.fn() }, notifications: { create: vi.fn() }, $transaction: vi.fn() @@ -47,6 +47,7 @@ const customerCookie = "userToken=customer-token"; describe("PUT /products/:id", () => { beforeEach(() => { vi.clearAllMocks(); + prisma.orders.count.mockResolvedValue(0); }); it("Retorna 200 y actualiza el stock cuando el seller modifica la cantidad", async () => { @@ -105,6 +106,7 @@ describe("PUT /products/:id", () => { describe("POST /api/users/:customerId/cart/items", () => { beforeEach(() => { vi.clearAllMocks(); + prisma.orders.count.mockResolvedValue(0); }); it("Retorna 400 cuando el producto no tiene stock disponible", async () => { @@ -194,6 +196,7 @@ describe("POST /api/users/:customerId/cart/items", () => { describe("PUT /api/users/cart/items/:cartItemId", () => { beforeEach(() => { vi.clearAllMocks(); + prisma.orders.count.mockResolvedValue(0); }); it("Retorna 400 cuando la cantidad solicitada supera el stock disponible", async () => { @@ -219,6 +222,7 @@ describe("PUT /api/users/cart/items/:cartItemId", () => { describe("POST /api/orders", () => { beforeEach(() => { vi.clearAllMocks(); + prisma.orders.count.mockResolvedValue(0); }); it("Retorna 201 y decrementa el stock al confirmar una orden con stock suficiente", async () => { @@ -334,6 +338,7 @@ describe("POST /api/orders", () => { describe("GET /api/users/:customerId/carts", () => { beforeEach(() => { vi.clearAllMocks(); + prisma.orders.count.mockResolvedValue(0); }); it("Retorna 200 con campo stock en items: positivo para producto con stock y cero para producto agotado", async () => { @@ -400,6 +405,7 @@ describe("GET /api/users/:customerId/carts", () => { describe("GET /api/users/cart/:cartId/items", () => { beforeEach(() => { vi.clearAllMocks(); + prisma.orders.count.mockResolvedValue(0); }); it("Retorna 200 con campo stock en items: positivo para producto con stock y cero para producto agotado", async () => { diff --git a/tests/e2e/stores.test.js b/tests/e2e/stores.test.js index 4d1f9102..669fcca4 100644 --- a/tests/e2e/stores.test.js +++ b/tests/e2e/stores.test.js @@ -258,7 +258,8 @@ describe("POST /api/commerces", () => { .send({ name: "Solo Nombre" }); expect(res.status).toBe(400); - expect(res.body.message).toMatch(/campos obligatorios/i); + expect(res.body.message).toBe("Error de validación"); + expect(res.body.errors.length).toBeGreaterThan(0); }); it("devuelve 400 cuando el email tiene formato inválido", async () => { @@ -269,6 +270,7 @@ describe("POST /api/commerces", () => { .post("/api/commerces") .set("Cookie", `userToken=${sellerToken}`) .send({ + fk_user: 1, category_ids: [1], name: "Tienda", email: "email-invalido", @@ -281,7 +283,11 @@ describe("POST /api/commerces", () => { }); expect(res.status).toBe(400); - expect(res.body.message).toMatch(/email/i); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "email" }), + ]) + ); }); it("devuelve 409 cuando el usuario ya tiene un comercio registrado", async () => { @@ -292,6 +298,7 @@ describe("POST /api/commerces", () => { .post("/api/commerces") .set("Cookie", `userToken=${sellerToken}`) .send({ + fk_user: 1, category_ids: [1], name: "Tienda", email: "nueva@test.com", @@ -349,6 +356,7 @@ describe("POST /api/commerces", () => { .post("/api/commerces") .set("Cookie", `userToken=${sellerToken}`) .send({ + fk_user: 1, category_ids: [1], name: "Nueva Tienda", email: "nueva@test.com", @@ -394,7 +402,11 @@ describe("PUT /api/commerces/:id", () => { .send({}); expect(res.status).toBe(400); - expect(res.body.message).toMatch(/al menos un campo/i); + expect(res.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: expect.stringMatching(/al menos un campo/i) }), + ]) + ); }); it("devuelve 200 con el comercio actualizado", async () => { diff --git a/tests/e2e/users.test.js b/tests/e2e/users.test.js index ba1a7983..6e2903c2 100644 --- a/tests/e2e/users.test.js +++ b/tests/e2e/users.test.js @@ -293,7 +293,7 @@ describe("PUT /api/users/:id_user/addresses/:id_address", () => { .send({}); expect(res.status).toBe(400); - expect(res.body.message).toMatch(/al menos uno/i); + expect(res.body.errors[0].message).toMatch(/al menos un/i); }); it("devuelve 200 con la dirección actualizada", async () => { diff --git a/tests/unit/delivery/delivery-complete.test.js b/tests/unit/delivery/delivery-complete.test.js index 94d72220..808913bd 100644 --- a/tests/unit/delivery/delivery-complete.test.js +++ b/tests/unit/delivery/delivery-complete.test.js @@ -6,6 +6,7 @@ import { prisma } from "../../../src/lib/prisma.js"; // Creamos los fns mockeados fuera para poder acceder a ellos en los tests const mockAssignmentsUpdate = vi.fn(); +const mockOrdersFindUnique = vi.fn(); const mockOrdersUpdate = vi.fn(); vi.mock("../../../src/lib/prisma.js", () => ({ @@ -24,7 +25,7 @@ vi.mock("../../../src/lib/prisma.js", () => ({ $transaction: vi.fn((callback) => callback({ deliveryAssignments: { update: mockAssignmentsUpdate }, - orders: { update: mockOrdersUpdate }, + orders: { findUnique: mockOrdersFindUnique, update: mockOrdersUpdate }, }) ), }, @@ -61,6 +62,7 @@ describe("POST /api/assignments/:id/complete", () => { beforeEach(() => { vi.clearAllMocks(); mockAssignmentsUpdate.mockReset(); + mockOrdersFindUnique.mockReset(); mockOrdersUpdate.mockReset(); }); @@ -135,6 +137,7 @@ describe("POST /api/assignments/:id/complete", () => { it("devuelve 200 y marca la asignación como DELIVERED", async () => { prisma.deliveryAssignments.findUnique.mockResolvedValue(mockAssignmentAccepted); + mockOrdersFindUnique.mockResolvedValue({ order_status: "SHIPPED" }); mockAssignmentsUpdate.mockResolvedValue({ ...mockAssignmentAccepted, assignment_status: "DELIVERED", @@ -155,6 +158,7 @@ describe("POST /api/assignments/:id/complete", () => { it("también actualiza el order_status a DELIVERED al completar", async () => { prisma.deliveryAssignments.findUnique.mockResolvedValue(mockAssignmentAccepted); + mockOrdersFindUnique.mockResolvedValue({ order_status: "SHIPPED" }); mockAssignmentsUpdate.mockResolvedValue({ ...mockAssignmentAccepted, assignment_status: "DELIVERED", diff --git a/tests/unit/delivery/delivery-profile.test.js b/tests/unit/delivery/delivery-profile.test.js index 34db0812..557dd719 100644 --- a/tests/unit/delivery/delivery-profile.test.js +++ b/tests/unit/delivery/delivery-profile.test.js @@ -254,12 +254,11 @@ describe("PUT /api/deliveries/:id — Actualizar perfil de delivery", () => { // --- Sin cambios (body vacío) --- - it("devuelve 200 cuando se envía body vacío (sin cambios)", async () => { - setupProfileMocks(); - + it("devuelve 400 cuando se envía body vacío", async () => { const res = await putDelivery(1, {}); - expect(res.status).toBe(200); + expect(res.status).toBe(400); + expect(res.body.message).toBe("Error de validación"); expect(prisma.users.update).not.toHaveBeenCalled(); expect(prisma.deliveries.update).not.toHaveBeenCalled(); }); @@ -267,10 +266,12 @@ describe("PUT /api/deliveries/:id — Actualizar perfil de delivery", () => { // --- Error interno --- it("devuelve 500 cuando prisma falla inesperadamente", async () => { + prisma.deliveries.findUnique.mockReset(); prisma.deliveries.findUnique.mockRejectedValue(new Error("Database error")); const res = await putDelivery(1, { name: "Nombre" }); expect(res.status).toBe(500); + expect(res.body.error.message).toBe("Database error"); }); }); diff --git a/tests/unit/dtos/misc.dto.test.js b/tests/unit/dtos/misc.dto.test.js index 20ba6661..364e9071 100644 --- a/tests/unit/dtos/misc.dto.test.js +++ b/tests/unit/dtos/misc.dto.test.js @@ -335,16 +335,16 @@ describe("FilterWishlistDTO", () => { describe("CreateWishlistItemDTO", () => { it("acepta datos válidos", () => { - const result = CreateWishlistItemDTO.safeParse({ fk_product: 1, quantity: 2 }); + const result = CreateWishlistItemDTO.safeParse({ productId: 1, quantity: 2 }); expect(result.success).toBe(true); }); it("falla cuando quantity es 0", () => { - const result = CreateWishlistItemDTO.safeParse({ fk_product: 1, quantity: 0 }); + const result = CreateWishlistItemDTO.safeParse({ productId: 1, quantity: 0 }); expect(result.success).toBe(false); }); - it("falla cuando falta fk_product", () => { + it("falla cuando falta productId", () => { const result = CreateWishlistItemDTO.safeParse({ quantity: 1 }); expect(result.success).toBe(false); }); diff --git a/tests/unit/lib/security-logger.test.js b/tests/unit/lib/security-logger.test.js new file mode 100644 index 00000000..ba947a5d --- /dev/null +++ b/tests/unit/lib/security-logger.test.js @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + sanitizeForLog, + logSecurityEvent, + buildSecurityLogLine, + normalizeKey, +} from "../../../src/lib/security-logger.js"; + +describe("security-logger", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("normalizeKey", () => { + it("convierte camelCase a snake_case", () => { + expect(normalizeKey("accessToken")).toBe("access_token"); + expect(normalizeKey("passwordHash")).toBe("password_hash"); + expect(normalizeKey("refreshToken")).toBe("refresh_token"); + }); + }); + + describe("sanitizeForLog", () => { + it("redacta claves sensibles en snake_case y camelCase", () => { + const input = { + email: "user@test.com", + password: "secret123", + token: "abc", + userToken: "cookie-token", + accessToken: "at-123", + refreshToken: "rt-456", + passwordHash: "hash-value", + }; + + expect(sanitizeForLog(input)).toEqual({ + email: "user@test.com", + password: "[REDACTED]", + token: "[REDACTED]", + userToken: "[REDACTED]", + accessToken: "[REDACTED]", + refreshToken: "[REDACTED]", + passwordHash: "[REDACTED]", + }); + }); + + it("redacta JWT y Bearer en strings", () => { + const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.sig"; + const input = { + message: `Login exitoso ${jwt}`, + header: `Bearer ${jwt}`, + }; + + const sanitized = sanitizeForLog(input); + expect(sanitized.message).not.toContain(jwt); + expect(sanitized.header).not.toContain("Bearer eyJ"); + expect(sanitized.message).toContain("[REDACTED_JWT]"); + }); + + it("elimina caracteres de control para evitar log injection", () => { + const sanitized = sanitizeForLog({ path: "/api/test\nFAKE: injected" }); + expect(sanitized.path).not.toContain("\n"); + expect(sanitized.path).toBe("/api/testFAKE: injected"); + }); + }); + + describe("buildSecurityLogLine", () => { + it("no permite que details sobrescriba event ni timestamp reservados", () => { + const line = buildSecurityLogLine("ORDER_STATUS_CHANGED", { + event: "MALICIOUS", + timestamp: "1970-01-01T00:00:00.000Z", + orderId: 1, + }); + + const parsed = JSON.parse(line); + expect(parsed.event).toBe("ORDER_STATUS_CHANGED"); + expect(parsed.timestamp).not.toBe("1970-01-01T00:00:00.000Z"); + expect(parsed.orderId).toBe(1); + }); + }); + + describe("logSecurityEvent", () => { + it("escribe JSON sanitizado sin passwords ni tokens", () => { + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + logSecurityEvent("LOGIN_FAILED", { + email: "user@test.com", + password: "no-debe-aparecer", + token: "no-debe-aparecer", + }); + + expect(writeSpy).toHaveBeenCalledOnce(); + const output = writeSpy.mock.calls[0][0]; + expect(output).toMatch(/^\[SECURITY\] /); + const raw = output.replace(/^\[SECURITY\] /, "").trimEnd(); + expect(raw).not.toContain("no-debe-aparecer"); + const parsed = JSON.parse(raw); + expect(parsed.event).toBe("LOGIN_FAILED"); + expect(parsed.password).toBe("[REDACTED]"); + expect(parsed.token).toBe("[REDACTED]"); + expect(parsed.email).toBe("user@test.com"); + }); + + it("no relanza si falla el logging", () => { + vi.spyOn(process.stdout, "write").mockImplementation(() => { + throw new Error("logging down"); + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => logSecurityEvent("LOGIN_FAILED", { email: "a@b.com" })).not.toThrow(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it("descarta líneas inválidas sin escribir", () => { + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + logSecurityEvent("", { email: "a@b.com" }); + + expect(writeSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/middlewares/security-log.middleware.test.js b/tests/unit/middlewares/security-log.middleware.test.js new file mode 100644 index 00000000..bbea7762 --- /dev/null +++ b/tests/unit/middlewares/security-log.middleware.test.js @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { securityResponseLogger } from "../../../src/middlewares/security-log.middleware.js"; +import * as securityLogger from "../../../src/lib/security-logger.js"; + +describe("securityResponseLogger", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("registra FORBIDDEN cuando la respuesta es 403", () => { + const logSpy = vi.spyOn(securityLogger, "logSecurityEvent").mockImplementation(() => {}); + + const req = { + method: "PATCH", + originalUrl: "/api/orders/1/status", + user: { id_user: 5, role: "SELLER" }, + }; + + const listeners = {}; + const res = { + statusCode: 403, + on: vi.fn((event, handler) => { + listeners[event] = handler; + }), + }; + + const next = vi.fn(); + securityResponseLogger(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + listeners.finish(); + + expect(logSpy).toHaveBeenCalledWith("FORBIDDEN", { + method: "PATCH", + path: "/api/orders/1/status", + userId: 5, + role: "SELLER", + }); + }); + + it("no registra eventos para otros códigos HTTP", () => { + const logSpy = vi.spyOn(securityLogger, "logSecurityEvent").mockImplementation(() => {}); + + const req = { method: "GET", path: "/api/test" }; + const listeners = {}; + const res = { + statusCode: 200, + on: vi.fn((event, handler) => { + listeners[event] = handler; + }), + }; + + const next = vi.fn(); + securityResponseLogger(req, res, next); + listeners.finish(); + + expect(next).toHaveBeenCalledOnce(); + expect(logSpy).not.toHaveBeenCalled(); + }); +});