feat(config): modulo Configuraciones admin + permisos por rol
- Tablas ConfiguracionSistema (7 configs) + RolConfiguracion (6 roles) - Endpoints: config/sistema GET/PATCH, config/roles GET/PATCH, config/usuarios GET/PATCH, config/reset-roles POST, config/acciones GET - modulosDeRol() async con fallback a MATRIZ estatica - auth/me devuelve acciones[] segun rol del usuario - saludMonday.estaDisponible() lee modo_local_forzado de DB (cache 30s) - Landing: vista Configuraciones con 4 tabs (Modo, Roles, Usuarios, Sistema) - Mi-dia: boton Descartar se deshabilita si !descartar_lead - 630 API tests + 24 landing tests + 20 mi-dia tests verdes Seed: npm run seed:config
This commit is contained in:
206
api-gateway/src/controllers/config.controller.test.ts
Normal file
206
api-gateway/src/controllers/config.controller.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
import {
|
||||
requireAdmin,
|
||||
obtenerConfigsEndpoint,
|
||||
actualizarConfigsEndpoint,
|
||||
obtenerRolesConfigEndpoint,
|
||||
actualizarRolConfigEndpoint,
|
||||
obtenerUsuariosEndpoint,
|
||||
actualizarUsuarioEndpoint,
|
||||
resetRolesEndpoint,
|
||||
obtenerAccionesEndpoint,
|
||||
} from './config.controller.js'
|
||||
|
||||
// ---------- Servicio mockeado ----------
|
||||
const servicioMock = vi.hoisted(() => ({
|
||||
obtenerConfigs: vi.fn(),
|
||||
actualizarConfigs: vi.fn(),
|
||||
obtenerRolesConfig: vi.fn(),
|
||||
actualizarRolConfig: vi.fn(),
|
||||
obtenerUsuarios: vi.fn(),
|
||||
actualizarUsuario: vi.fn(),
|
||||
resetRoles: vi.fn(),
|
||||
obtenerAcciones: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/config.service.js', () => servicioMock)
|
||||
|
||||
function reqFake(overrides: Partial<Request> = {}): Request {
|
||||
return {
|
||||
body: {},
|
||||
params: {},
|
||||
usuarioToken: { sub: 'u-1', username: 'admin', rol: 'admin' },
|
||||
...overrides,
|
||||
} as Request
|
||||
}
|
||||
|
||||
function resFake() {
|
||||
const json = vi.fn()
|
||||
const end = vi.fn()
|
||||
const status = vi.fn().mockReturnValue({ json, end })
|
||||
return { res: { json, status, end } as unknown as Response, status, json, end }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ── requireAdmin ───────────────────────────────────────────────────────
|
||||
|
||||
describe('requireAdmin', () => {
|
||||
it('next() si rol es admin', () => {
|
||||
const next = vi.fn()
|
||||
requireAdmin(reqFake(), {} as Response, next)
|
||||
expect(next).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lanza 403 si rol no es admin', () => {
|
||||
expect(() =>
|
||||
requireAdmin(reqFake({ usuarioToken: { sub: 'u-2', username: 'x', rol: 'vendedor' } }), {} as Response, vi.fn()),
|
||||
).toThrow('Se requiere rol admin')
|
||||
})
|
||||
|
||||
it('lanza 403 si no hay token', () => {
|
||||
expect(() =>
|
||||
requireAdmin(reqFake({ usuarioToken: undefined }), {} as Response, vi.fn()),
|
||||
).toThrow('Se requiere rol admin')
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/config/sistema ────────────────────────────────────────────
|
||||
|
||||
describe('obtenerConfigsEndpoint', () => {
|
||||
it('200 con todas las configs', async () => {
|
||||
servicioMock.obtenerConfigs.mockResolvedValue({ a: '1' })
|
||||
const { res, json } = resFake()
|
||||
await obtenerConfigsEndpoint(reqFake(), res)
|
||||
expect(json).toHaveBeenCalledWith({ a: '1' })
|
||||
})
|
||||
})
|
||||
|
||||
// ── PATCH /api/config/sistema ──────────────────────────────────────────
|
||||
|
||||
describe('actualizarConfigsEndpoint', () => {
|
||||
it('204 tras actualizar configs parciales', async () => {
|
||||
servicioMock.actualizarConfigs.mockResolvedValue(undefined)
|
||||
const { res, end } = resFake()
|
||||
await actualizarConfigsEndpoint(reqFake({ body: { x: '1' } }), res)
|
||||
expect(servicioMock.actualizarConfigs).toHaveBeenCalledWith({ x: '1' })
|
||||
expect(end).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400 si body vacío', async () => {
|
||||
const { res } = resFake()
|
||||
await expect(actualizarConfigsEndpoint(reqFake({ body: {} }), res)).rejects.toThrow('Body vacío')
|
||||
})
|
||||
|
||||
it('400 si body inválido (ZodError)', async () => {
|
||||
const { res } = resFake()
|
||||
await expect(actualizarConfigsEndpoint(reqFake({ body: { x: 123 } }), res)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/config/roles ──────────────────────────────────────────────
|
||||
|
||||
describe('obtenerRolesConfigEndpoint', () => {
|
||||
it('200 con roles parseados', async () => {
|
||||
servicioMock.obtenerRolesConfig.mockResolvedValue([
|
||||
{ rol: 'admin', modulos: '["mi-dia"]', acciones: '{"x":true}', updatedAt: new Date() },
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
await obtenerRolesConfigEndpoint(reqFake(), res)
|
||||
expect(json).toHaveBeenCalledWith([
|
||||
{ rol: 'admin', modulos: ['mi-dia'], acciones: { x: true }, updatedAt: expect.any(Date) },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// ── PATCH /api/config/roles/:rol ───────────────────────────────────────
|
||||
|
||||
describe('actualizarRolConfigEndpoint', () => {
|
||||
it('204 tras actualizar un rol', async () => {
|
||||
servicioMock.actualizarRolConfig.mockResolvedValue(undefined)
|
||||
const { res, end } = resFake()
|
||||
await actualizarRolConfigEndpoint(
|
||||
reqFake({ params: { rol: 'vendedor' }, body: { modulos: ['a'], acciones: { b: true } } }),
|
||||
res,
|
||||
)
|
||||
expect(servicioMock.actualizarRolConfig).toHaveBeenCalledWith('vendedor', {
|
||||
modulos: ['a'],
|
||||
acciones: { b: true },
|
||||
})
|
||||
expect(end).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400 si body inválido (faltan campos)', async () => {
|
||||
const { res } = resFake()
|
||||
await expect(
|
||||
actualizarRolConfigEndpoint(reqFake({ params: { rol: 'x' }, body: { modulos: 'no' } }), res),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/config/usuarios ───────────────────────────────────────────
|
||||
|
||||
describe('obtenerUsuariosEndpoint', () => {
|
||||
it('200 con la lista de usuarios', async () => {
|
||||
servicioMock.obtenerUsuarios.mockResolvedValue([{ id: 'u1', nombre: 'A' }])
|
||||
const { res, json } = resFake()
|
||||
await obtenerUsuariosEndpoint(reqFake(), res)
|
||||
expect(json).toHaveBeenCalledWith([{ id: 'u1', nombre: 'A' }])
|
||||
})
|
||||
})
|
||||
|
||||
// ── PATCH /api/config/usuarios/:id ─────────────────────────────────────
|
||||
|
||||
describe('actualizarUsuarioEndpoint', () => {
|
||||
it('204 tras actualizar un usuario', async () => {
|
||||
servicioMock.actualizarUsuario.mockResolvedValue(undefined)
|
||||
const { res, end } = resFake()
|
||||
await actualizarUsuarioEndpoint(
|
||||
reqFake({ params: { id: 'u1' }, body: { rol: 'admin' } }),
|
||||
res,
|
||||
)
|
||||
expect(servicioMock.actualizarUsuario).toHaveBeenCalledWith('u1', { rol: 'admin' })
|
||||
expect(end).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400 si body vacío', async () => {
|
||||
const { res } = resFake()
|
||||
await expect(
|
||||
actualizarUsuarioEndpoint(reqFake({ params: { id: 'u1' }, body: {} }), res),
|
||||
).rejects.toThrow('Body vacío')
|
||||
})
|
||||
})
|
||||
|
||||
// ── POST /api/config/reset-roles ───────────────────────────────────────
|
||||
|
||||
describe('resetRolesEndpoint', () => {
|
||||
it('204 tras resetear roles', async () => {
|
||||
servicioMock.resetRoles.mockResolvedValue(undefined)
|
||||
const { res, end } = resFake()
|
||||
await resetRolesEndpoint(reqFake(), res)
|
||||
expect(servicioMock.resetRoles).toHaveBeenCalled()
|
||||
expect(end).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── GET /api/config/acciones ───────────────────────────────────────────
|
||||
|
||||
describe('obtenerAccionesEndpoint', () => {
|
||||
it('200 con las acciones del rol del usuario', async () => {
|
||||
servicioMock.obtenerAcciones.mockResolvedValue({ descartar_lead: true })
|
||||
const { res, json } = resFake()
|
||||
await obtenerAccionesEndpoint(reqFake(), res)
|
||||
expect(servicioMock.obtenerAcciones).toHaveBeenCalledWith('admin')
|
||||
expect(json).toHaveBeenCalledWith({ descartar_lead: true })
|
||||
})
|
||||
|
||||
it('400 sin token verificado', async () => {
|
||||
const { res } = resFake()
|
||||
await expect(obtenerAccionesEndpoint(reqFake({ usuarioToken: undefined }), res)).rejects.toThrow(
|
||||
'Falta el token verificado',
|
||||
)
|
||||
})
|
||||
})
|
||||
111
api-gateway/src/controllers/config.controller.ts
Normal file
111
api-gateway/src/controllers/config.controller.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Endpoints de administración de configuración.
|
||||
*
|
||||
* Protegidos con requireAdmin (rol === 'admin') excepto /acciones que
|
||||
* cualquiera autenticado puede consultar.
|
||||
*/
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import * as configService from '../services/config.service.js'
|
||||
import { AppError, BadRequestError } from '../utils/errors.js'
|
||||
|
||||
// ── Middleware admin ────────────────────────────────────────────────────
|
||||
|
||||
export function requireAdmin(req: Request, _res: Response, next: NextFunction): void {
|
||||
if (req.usuarioToken?.rol !== 'admin') {
|
||||
throw new AppError(403, 'Se requiere rol admin')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// ── GET /api/config/sistema ────────────────────────────────────────────
|
||||
|
||||
export async function obtenerConfigsEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
const configs = await configService.obtenerConfigs()
|
||||
res.json(configs)
|
||||
}
|
||||
|
||||
// ── PATCH /api/config/sistema ──────────────────────────────────────────
|
||||
|
||||
const actualizarConfigsSchema = z.record(z.string(), z.string())
|
||||
|
||||
export async function actualizarConfigsEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const parcial = actualizarConfigsSchema.parse(req.body)
|
||||
if (Object.keys(parcial).length === 0) throw new BadRequestError('Body vacío')
|
||||
await configService.actualizarConfigs(parcial)
|
||||
res.status(204).end()
|
||||
}
|
||||
|
||||
// ── GET /api/config/roles ──────────────────────────────────────────────
|
||||
|
||||
export async function obtenerRolesConfigEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
const roles = await configService.obtenerRolesConfig()
|
||||
// Parsear JSON para que el frontend reciba objetos
|
||||
res.json(
|
||||
roles.map((r) => ({
|
||||
rol: r.rol,
|
||||
modulos: JSON.parse(r.modulos) as string[],
|
||||
acciones: JSON.parse(r.acciones) as Record<string, boolean>,
|
||||
updatedAt: r.updatedAt,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
// ── PATCH /api/config/roles/:rol ───────────────────────────────────────
|
||||
|
||||
const actualizarRolSchema = z.object({
|
||||
modulos: z.array(z.string()),
|
||||
acciones: z.record(z.string(), z.boolean()),
|
||||
})
|
||||
|
||||
export async function actualizarRolConfigEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const rol = req.params.rol as string
|
||||
if (!rol) throw new BadRequestError('Falta el parámetro :rol')
|
||||
const body = actualizarRolSchema.parse(req.body)
|
||||
await configService.actualizarRolConfig(rol, body)
|
||||
res.status(204).end()
|
||||
}
|
||||
|
||||
// ── GET /api/config/usuarios ───────────────────────────────────────────
|
||||
|
||||
export async function obtenerUsuariosEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
const usuarios = await configService.obtenerUsuarios()
|
||||
res.json(usuarios)
|
||||
}
|
||||
|
||||
// ── PATCH /api/config/usuarios/:id ─────────────────────────────────────
|
||||
|
||||
const actualizarUsuarioSchema = z.object({
|
||||
rol: z.string().optional(),
|
||||
asignacion: z.string().nullable().optional(),
|
||||
activo: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export async function actualizarUsuarioEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const id = req.params.id as string
|
||||
if (!id) throw new BadRequestError('Falta el parámetro :id')
|
||||
const parsed = actualizarUsuarioSchema.parse(req.body)
|
||||
// Filtra undefined para enviar solo campos con valor
|
||||
const body: { rol?: string; asignacion?: string | null; activo?: boolean } = {}
|
||||
if (parsed.rol !== undefined) body.rol = parsed.rol
|
||||
if (parsed.asignacion !== undefined) body.asignacion = parsed.asignacion
|
||||
if (parsed.activo !== undefined) body.activo = parsed.activo
|
||||
if (Object.keys(body).length === 0) throw new BadRequestError('Body vacío')
|
||||
await configService.actualizarUsuario(id, body)
|
||||
res.status(204).end()
|
||||
}
|
||||
|
||||
// ── POST /api/config/reset-roles ───────────────────────────────────────
|
||||
|
||||
export async function resetRolesEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
await configService.resetRoles()
|
||||
res.status(204).end()
|
||||
}
|
||||
|
||||
// ── GET /api/config/acciones ───────────────────────────────────────────
|
||||
|
||||
export async function obtenerAccionesEndpoint(req: Request, res: Response): Promise<void> {
|
||||
if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado')
|
||||
const acciones = await configService.obtenerAcciones(req.usuarioToken.rol)
|
||||
res.json(acciones)
|
||||
}
|
||||
@@ -93,6 +93,7 @@ describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () =
|
||||
asignacion: 'MONTEBELLO',
|
||||
mondayUserId: 13676,
|
||||
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
|
||||
acciones: [],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function actualizarVinculoMondayEndpoint(req: Request, res: Respons
|
||||
where: { id: req.usuarioToken.sub },
|
||||
data: { mondayUserId },
|
||||
})
|
||||
res.json(aPublico(usuario))
|
||||
res.json(await aPublico(usuario))
|
||||
}
|
||||
|
||||
const cambiarRolSchema = z.object({
|
||||
@@ -65,5 +65,5 @@ export async function actualizarRolEndpoint(req: Request, res: Response): Promis
|
||||
where: { id: req.params.id as string },
|
||||
data: { rol },
|
||||
})
|
||||
res.json(aPublico(usuario))
|
||||
res.json(await aPublico(usuario))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user