import { ForbiddenException } from '@nestjs/common'; import { vi } from 'vitest'; import type { AuditService } from '../audit/audit.service.js'; import type { AppConfigService } from '../config/app-config.service.js'; import type { AuthUser } from '../auth/auth.types.js'; import type { PermissionsService } from '../permissions/permissions.service.js'; import type { SapClientService } from '../sap/sap-client.service.js'; import { SlProxyService } from './sl-proxy.service.js'; const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' }; describe('SlProxyService: auditoría', () => { let request: ReturnType; let record: ReturnType; let paths: string[]; let service: SlProxyService; beforeEach(() => { request = vi.fn(async () => ({ data: { DocEntry: 316, DocNum: 12 } })); record = vi.fn(async () => undefined); paths = ['*']; service = new SlProxyService( { request } as unknown as SapClientService, { getPaths: async () => paths } as unknown as PermissionsService, { record } as unknown as AuditService, ); }); it('crear queda registrado con la clave creada y solo los nombres de los campos', async () => { await service.create(user, 'Orders', { CardCode: 'C1', Comments: 'privado', DocumentLines: [{ ItemCode: 'A' }, { ItemCode: 'B' }] }, {}, '10.0.0.5'); expect(record).toHaveBeenCalledWith({ action: 'sl_create', webUser: 'ana', ip: '10.0.0.5', resource: 'Orders', key: '316', detail: { fields: ['CardCode', 'Comments'], collections: { DocumentLines: 2 } }, }); expect(JSON.stringify(record.mock.calls)).not.toContain('privado'); }); it('modificar queda registrado con su clave y los campos tocados', async () => { request.mockResolvedValueOnce({ data: undefined }); await service.update(user, 'BusinessPartners', 'C100', { Phone1: '123' }, {}, '10.0.0.5'); expect(record).toHaveBeenCalledWith({ action: 'sl_update', webUser: 'ana', ip: '10.0.0.5', resource: 'BusinessPartners', key: 'C100', detail: { fields: ['Phone1'] } }); }); it('si SAP rechaza la operación queda registrada como error, con el mensaje, y el error sigue su camino', async () => { request.mockRejectedValueOnce(new Error('1250000110 - RUT is missing')); await expect(service.create(user, 'BusinessPartners', { CardCode: 'C9' }, {}, '10.0.0.5')).rejects.toThrow('RUT is missing'); expect(record).toHaveBeenCalledWith({ action: 'sl_create', webUser: 'ana', ip: '10.0.0.5', resource: 'BusinessPartners', outcome: 'error', detail: { fields: ['CardCode'], error: '1250000110 - RUT is missing' }, }); request.mockRejectedValueOnce(new Error('412')); await expect(service.update(user, 'Orders', '5', { Comments: 'x' }, {}, '10.0.0.5')).rejects.toThrow('412'); expect(record).toHaveBeenLastCalledWith(expect.objectContaining({ action: 'sl_update', key: '5', outcome: 'error' })); }); it('un usuario sin permiso de escritura queda registrado como denegado y no llega a SAP', async () => { paths = ['/ventas/ordenes']; await expect(service.create(user, 'Invoices', { CardCode: 'C1' }, {}, '10.0.0.5')).rejects.toBeInstanceOf(ForbiddenException); expect(request).not.toHaveBeenCalled(); expect(record).toHaveBeenCalledWith({ action: 'sl_denied', webUser: 'ana', ip: '10.0.0.5', resource: 'Invoices', outcome: 'denied', detail: { method: 'POST' } }); }); it('las acciones de solo consulta (series de numeración) no se auditan', async () => { request.mockResolvedValueOnce({ data: { value: [] } }); await service.create(user, 'SeriesService_GetDocumentSeries', { DocumentTypeParams: { Document: '17' } }, {}, '10.0.0.5'); expect(record).not.toHaveBeenCalled(); }); it('las lecturas no se auditan', async () => { request.mockResolvedValueOnce({ data: { value: [] } }); await service.list(user, 'Orders', {}, {}); request.mockResolvedValueOnce({ data: {} }); await service.get(user, 'Orders', '5', {}); expect(record).not.toHaveBeenCalled(); }); describe('acciones sobre un registro (anular un pago)', () => { it('anular un pago llama a Entidad(clave)/Cancel y queda registrado', async () => { request.mockResolvedValueOnce({ data: undefined }); await service.action(user, 'IncomingPayments', '512', 'Cancel', '10.0.0.5'); expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST', path: 'IncomingPayments(512)/Cancel' })); expect(record).toHaveBeenCalledWith({ action: 'sl_action', webUser: 'ana', ip: '10.0.0.5', resource: 'IncomingPayments', key: '512', detail: { action: 'Cancel' } }); }); it('una acción que no está en la lista blanca del recurso no llega a SAP', async () => { await expect(service.action(user, 'IncomingPayments', '512', 'Delete', '10.0.0.5')).rejects.toThrow(/no disponible/i); await expect(service.action(user, 'Orders', '5', 'Cancel', '10.0.0.5')).rejects.toThrow(/no disponible/i); expect(request).not.toHaveBeenCalled(); }); it('sin permiso de escritura queda denegado y no llega a SAP', async () => { paths = ['/bancos/pagos-efectuados']; await expect(service.action(user, 'IncomingPayments', '512', 'Cancel', '10.0.0.5')).rejects.toBeInstanceOf(ForbiddenException); expect(request).not.toHaveBeenCalled(); expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'sl_denied', resource: 'IncomingPayments', outcome: 'denied' })); }); it('si SAP rechaza la anulación queda registrada como error y el error sigue su camino', async () => { request.mockRejectedValueOnce(new Error('ya está anulado')); await expect(service.action(user, 'VendorPayments', '9', 'Cancel', '10.0.0.5')).rejects.toThrow('ya está anulado'); expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'sl_action', resource: 'VendorPayments', key: '9', outcome: 'error', detail: { action: 'Cancel', error: 'ya está anulado' } })); }); it('la clave del registro se valida (solo números en los pagos)', async () => { await expect(service.action(user, 'IncomingPayments', "1);DROP", 'Cancel', '10.0.0.5')).rejects.toThrow(); expect(request).not.toHaveBeenCalled(); }); }); }); describe('SlProxyService: modo solo lectura', () => { const make = (readOnly: boolean) => { const request = vi.fn(async () => ({ data: { value: [] } })); const record = vi.fn(async () => undefined); const service = new SlProxyService( { request } as unknown as SapClientService, { getPaths: async () => ['*'] } as unknown as PermissionsService, { record } as unknown as AuditService, { get: (k: string) => (k === 'SAPB1_SL_READ_ONLY' ? readOnly : undefined) } as unknown as AppConfigService, ); return { service, request, record }; }; it('rechaza crear, modificar y las acciones sin llegar a SAP, y lo audita', async () => { const { service, request, record } = make(true); await expect(service.create(user, 'Orders', { CardCode: 'C1' }, {}, '10.0.0.5')).rejects.toThrow('solo lectura'); await expect(service.update(user, 'Orders', '1', { Comments: 'x' }, {}, '10.0.0.5')).rejects.toThrow('solo lectura'); await expect(service.action(user, 'IncomingPayments', '1', 'Cancel', '10.0.0.5')).rejects.toThrow('solo lectura'); expect(request).not.toHaveBeenCalled(); expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'sl_denied', outcome: 'denied' })); }); it('las consultas y las acciones de solo consulta (series) siguen funcionando', async () => { const { service, request } = make(true); await service.list(user, 'Orders', {}, {}); await service.create(user, 'SeriesService_GetDocumentSeries', { DocumentTypeParams: { Document: '30' } }, {}, '10.0.0.5'); expect(request).toHaveBeenCalledTimes(2); }); it('sin el modo solo lectura todo sigue igual', async () => { const { service, request } = make(false); await service.create(user, 'Orders', { CardCode: 'C1' }, {}, '10.0.0.5'); expect(request).toHaveBeenCalledTimes(1); }); });