feat(api): proxy del Service Layer con registro de recursos

El frontend envía parámetros OData y cuerpo; el backend valida y ejecuta.

- GET lista/por clave, POST y PATCH sobre /api/sl/:recurso, solo para
  recursos registrados (ventas, compras, inventario y maestros estándar);
  EmployeesInfo no se registra nunca (contiene U_WEB_PASS).
- Permisos de menú por recurso (lectura/escritura); catálogos de consulta
  abiertos a cualquier sesión. Evaluador de patrones compartible
  (/inventario/* ya no habilita /inventario-x).
- Parámetros OData validados con lista cerrada, $top máx. 500, $expand solo
  si el recurso lo declara, claves validadas y escapadas por tipo, cabeceras
  B1S-* con lista blanca. $inlinecount y B1S-PageSize automáticos.
- configureApp() compartido entre main.ts y e2e (prefijo y límite de 5 mb).
- Verificado contra SAP real: los 27 recursos existen; listado con total,
  filtro, paginación y claves. InventoryRequests no existe en el SL: se
  registra InventoryTransferRequests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 19:04:52 -05:00
1 parent 73fffee2f7
commit c0990ca83e
15 files changed
+683 -6

No files matched your search

+8
View File
@@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing';
import type { INestApplication } from '@nestjs/common';
import { AppModule } from '../src/app.module.js';
import { SapClientService } from '../src/sap/sap-client.service.js';
import { SL_RESOURCES } from '../src/sl/sl-resources.js';
// Prueba contra un Service Layer real (usa apps/api/.env). Solo con SAP_LIVE=1:
// SAP_LIVE=1 npm run test:e2e
@@ -26,4 +27,11 @@ describe.skipIf(!process.env.SAP_LIVE)('SAP Service Layer (live)', () => {
);
expect(res.every((r) => r.status === 200 && Array.isArray(r.data.value))).toBe(true);
}, 30000);
// Detecta nombres de entidad mal escritos o no disponibles en este Service Layer.
it.each(SL_RESOURCES.map((r) => r.name))('el recurso registrado %s existe en el Service Layer', async (name) => {
const res = await sap.get<{ value: unknown[] }>(name, { $top: 1 });
expect(res.status).toBe(200);
expect(Array.isArray(res.data.value)).toBe(true);
}, 30000);
});
+151
View File
@@ -0,0 +1,151 @@
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { createTestApp, employee, sapCalls } from './support/create-test-app.js';
describe('Proxy del Service Layer (e2e)', () => {
let app: INestApplication;
let admin: { Authorization: string };
let luis: { Authorization: string };
const http = () => request(app.getHttpServer());
const loginAs = async (username: string) => {
const res = await http().post('/api/auth/login').send({ username, password: 'secreto' }).expect(200);
return { Authorization: `Bearer ${res.body.token}` };
};
beforeEach(async () => {
app = await createTestApp([employee('ana', 1), employee('luis', 2)], 'ana', (req) =>
req.method === 'GET' ? { value: [{ ItemCode: 'A' }], 'odata.count': '1' } : { DocEntry: 77, DocNum: 1001 },
);
admin = await loginAs('ana');
luis = await loginAs('luis');
});
afterEach(async () => {
await app.close();
});
describe('listar', () => {
it('arma la consulta OData con total y tamaño de página', async () => {
const res = await http()
.get('/api/sl/Items')
.query({ $select: 'ItemCode, ItemName', $filter: "contains(ItemName,'TUBO')", $orderby: 'ItemCode', $top: 20, $skip: 40 })
.set(luis)
.expect(200);
expect(res.body).toEqual({ value: [{ ItemCode: 'A' }], 'odata.count': '1' });
expect(sapCalls.at(-1)).toMatchObject({
method: 'GET',
path: 'Items',
query: {
$select: 'ItemCode,ItemName',
$filter: "contains(ItemName,'TUBO')",
$orderby: 'ItemCode',
$top: 20,
$skip: 40,
$inlinecount: 'allpages',
},
headers: { 'B1S-PageSize': '20', 'B1S-CaseInsensitive': 'true' },
});
});
it('sin $top usa 50 y sin $filter no fuerza mayúsculas/minúsculas', async () => {
await http().get('/api/sl/Items').set(luis).expect(200);
const call = sapCalls.at(-1)!;
expect(call.query).toMatchObject({ $top: 50 });
expect(call.headers).toEqual({ 'B1S-PageSize': '50' });
});
it('rechaza parámetros no permitidos y $expand no declarado', async () => {
await http().get('/api/sl/Items').query({ $apply: 'x' }).set(luis).expect(400);
await http().get('/api/sl/Items').query({ $top: 100000 }).set(luis).expect(400);
await http().get('/api/sl/Items').query({ $expand: 'ItemPrices' }).set(luis).expect(400);
expect(sapCalls).toHaveLength(0);
});
});
describe('por clave', () => {
it('clave numérica y de texto', async () => {
await http().get('/api/sl/Orders/15').set(admin).expect(200);
expect(sapCalls.at(-1)!.path).toBe('Orders(15)');
await http().get("/api/sl/Items/O'NEIL").set(luis).expect(200);
expect(sapCalls.at(-1)!.path).toBe("Items('O''NEIL')");
});
it('no deja colar rutas ni acciones en la clave', async () => {
await http().get('/api/sl/Orders/1)%2FCancel').set(admin).expect(400);
await http().get('/api/sl/Orders/..%2FLogout').set(admin).expect(400);
expect(sapCalls).toHaveLength(0);
});
});
describe('recursos', () => {
it('lo no registrado es 404 (EmployeesInfo incluido)', async () => {
await http().get('/api/sl/EmployeesInfo').set(admin).expect(404);
await http().get('/api/sl/Logout').set(admin).expect(404);
await http().get('/api/sl/items').set(admin).expect(404);
expect(sapCalls).toHaveLength(0);
});
it('sin sesión es 401', async () => {
await http().get('/api/sl/Items').expect(401);
});
it('método no permitido: los catálogos de solo lectura no aceptan POST', async () => {
await http().post('/api/sl/Warehouses').set(admin).send({ WarehouseCode: 'X' }).expect(405);
await http().delete('/api/sl/Orders/1').set(admin).expect(404); // no hay ruta DELETE
});
});
describe('permisos de menú', () => {
it('un documento exige la ruta de menú; el catálogo de consulta no', async () => {
await http().get('/api/sl/Orders').set(luis).expect(403);
await http().get('/api/sl/Warehouses').set(luis).expect(200);
await http().put('/api/permissions/luis').set(admin).send({ paths: ['/ventas/*'] }).expect(200);
await http().get('/api/sl/Orders').set(luis).expect(200);
await http().get('/api/sl/PurchaseOrders').set(luis).expect(403);
});
it('escribir en un maestro exige el módulo correspondiente', async () => {
await http().put('/api/permissions/luis').set(admin).send({ paths: ['/socios/proveedores'] }).expect(200);
await http().post('/api/sl/BusinessPartners').set(luis).send({ CardCode: 'P1' }).expect(201);
await http().post('/api/sl/Items').set(luis).send({ ItemCode: 'X' }).expect(403);
});
});
describe('crear y modificar', () => {
it('POST devuelve lo que responde SAP y reenvía el cuerpo', async () => {
const body = { CardCode: 'C1', DocumentLines: [{ ItemCode: 'A', Quantity: 2 }] };
const res = await http().post('/api/sl/Orders').set(admin).send(body).expect(201);
expect(res.body).toEqual({ DocEntry: 77, DocNum: 1001 });
expect(sapCalls.at(-1)).toMatchObject({ method: 'POST', path: 'Orders', body });
});
it('PATCH responde 204 y reenvía B1S-ReplaceCollectionsOnPatch decidido por el frontend', async () => {
await http()
.patch('/api/sl/Orders/15')
.set(admin)
.set('B1S-ReplaceCollectionsOnPatch', 'true')
.send({ Comments: 'x' })
.expect(204);
expect(sapCalls.at(-1)).toMatchObject({
method: 'PATCH',
path: 'Orders(15)',
body: { Comments: 'x' },
headers: { 'B1S-ReplaceCollectionsOnPatch': 'true' },
});
});
it('rechaza cuerpos que no son objetos y cabeceras B1S-* no permitidas', async () => {
await http().post('/api/sl/Orders').set(admin).send([1, 2]).expect(400);
await http().post('/api/sl/Orders').set(admin).set('B1S-Atomic', 'x').send({}).expect(400);
await http().patch('/api/sl/Orders/1').set(admin).set('B1S-PageSize', '5').send({}).expect(400);
expect(sapCalls).toHaveLength(0);
});
it('un cuerpo grande (documento con muchas líneas) no se corta por tamaño', async () => {
const lines = Array.from({ length: 800 }, (_, i) => ({ ItemCode: `ITEM-${i}`, Quantity: 1, ItemDescription: 'x'.repeat(100) }));
await http().post('/api/sl/Orders').set(admin).send({ DocumentLines: lines }).expect(201);
});
});
});
+25 -3
View File
@@ -1,5 +1,6 @@
import { Test } from '@nestjs/testing';
import type { INestApplication } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
// Valores ficticios: los e2e no se conectan a SAP ni a un archivo SQLite.
process.env.SAPB1_SL_URL ??= 'https://sap.invalid:50000/b1s/v1';
@@ -26,8 +27,24 @@ export const employee = (webUser: string, id = 1, pass = 'secreto'): FakeEmploye
U_WEB_PASS: pass,
});
export interface FakeSapRequest {
method: string;
path: string;
query?: Record<string, unknown>;
body?: unknown;
headers?: Record<string, string>;
}
/** Llamadas que llegaron al "Service Layer" simulado (se vacía en cada createTestApp). */
export const sapCalls: FakeSapRequest[] = [];
/** App completa con EmployeesInfo y la sesión SAP simulados. */
export async function createTestApp(employees: FakeEmployee[], adminWebUsers = ''): Promise<INestApplication> {
export async function createTestApp(
employees: FakeEmployee[],
adminWebUsers = '',
onSapRequest: (req: FakeSapRequest) => unknown = () => ({}),
): Promise<INestApplication> {
sapCalls.length = 0;
process.env.ADMIN_WEB_USERS = adminWebUsers;
const { AppModule } = await import('../../src/app.module.js');
const { SapClientService } = await import('../../src/sap/sap-client.service.js');
@@ -39,13 +56,18 @@ export async function createTestApp(employees: FakeEmployee[], adminWebUsers = '
get: async (_path: string, q: { $filter: string }) => ({
data: { value: employees.filter((e) => q.$filter === `U_WEB_USER eq '${e.U_WEB_USER}'`) },
}),
request: async (req: FakeSapRequest) => {
sapCalls.push(req);
return { status: 200, headers: {}, data: onSapRequest(req) };
},
})
.overrideProvider(SapSessionService)
.useValue({ getSession: async () => ({}), status: () => ({ connected: true }), onModuleDestroy: () => undefined })
.compile();
const app = moduleRef.createNestApplication();
app.setGlobalPrefix('api');
const { configureApp } = await import('../../src/app.setup.js');
const app = moduleRef.createNestApplication<NestExpressApplication>();
configureApp(app);
await app.init();
return app;
}