From c0990ca83e4facc90e94538fc8562923df21f085 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 13 Oct 2026 19:04:52 -0500 Subject: [PATCH] feat(api): proxy del Service Layer con registro de recursos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/README.md | 21 +++ apps/api/src/app.module.ts | 3 +- apps/api/src/app.setup.ts | 8 + apps/api/src/main.ts | 6 +- apps/api/src/permissions/path-matcher.spec.ts | 27 ++++ apps/api/src/permissions/path-matcher.ts | 16 ++ apps/api/src/sl/sl-proxy.controller.ts | 63 ++++++++ apps/api/src/sl/sl-proxy.service.ts | 91 +++++++++++ apps/api/src/sl/sl-request.spec.ts | 76 +++++++++ apps/api/src/sl/sl-request.ts | 100 ++++++++++++ apps/api/src/sl/sl-resources.ts | 79 +++++++++ apps/api/src/sl/sl.module.ts | 12 ++ apps/api/test/sap-live.e2e-spec.ts | 8 + apps/api/test/sl-proxy.e2e-spec.ts | 151 ++++++++++++++++++ apps/api/test/support/create-test-app.ts | 28 +++- 15 files changed, 683 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/app.setup.ts create mode 100644 apps/api/src/permissions/path-matcher.spec.ts create mode 100644 apps/api/src/permissions/path-matcher.ts create mode 100644 apps/api/src/sl/sl-proxy.controller.ts create mode 100644 apps/api/src/sl/sl-proxy.service.ts create mode 100644 apps/api/src/sl/sl-request.spec.ts create mode 100644 apps/api/src/sl/sl-request.ts create mode 100644 apps/api/src/sl/sl-resources.ts create mode 100644 apps/api/src/sl/sl.module.ts create mode 100644 apps/api/test/sl-proxy.e2e-spec.ts diff --git a/apps/api/README.md b/apps/api/README.md index fce2edd..5e970fa 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -54,3 +54,24 @@ Se guardan en SQLite (`SQLITE_PATH`, por defecto `./data/app.sqlite`, fuera del - El sistema no deja quitar el acceso total al último administrador (409). - El esquema se sincroniza automáticamente (`synchronize`); pasar a migraciones cuando se estabilice por cliente. - Si `better-sqlite3` falla al instalar en una máquina nueva: `npm rebuild better-sqlite3`. + +## Proxy del Service Layer (`/api/sl`) + +El frontend arma los parámetros OData y el cuerpo; el backend solo valida y ejecuta (sesión SAP, cola, reintento y errores incluidos). El frontend envía el **nombre del recurso**, nunca la URL. + +| Llamada | Descripción | +|---|---| +| `GET /api/sl/:recurso?$select&$filter&$orderby&$top&$skip` | Lista. Devuelve `value` y `odata.count`. `$top` por defecto 50, máx. 500. | +| `GET /api/sl/:recurso/:clave?$select` | Un registro. La clave se valida y escapa según su tipo. | +| `POST /api/sl/:recurso` | Crea; devuelve lo que responde SAP (ej. `DocEntry`, `DocNum`). | +| `PATCH /api/sl/:recurso/:clave` | Modifica (204). | + +Reglas: +- **Solo recursos registrados** en `src/sl/sl-resources.ts`; el resto es 404. Nunca registrar `EmployeesInfo` (contiene `U_WEB_PASS`). +- Cada recurso declara clave, métodos y la ruta de menú que gobierna lectura/escritura. Los catálogos de consulta (`Items`, `Warehouses`...) se leen con cualquier sesión. +- Parámetros OData fuera de la lista (`$apply`...) → 400. `$expand` solo si el recurso declara qué colecciones admite. +- Cabeceras B1S-* permitidas: `B1S-ReplaceCollectionsOnPatch` (PATCH), `B1S-CaseInsensitive` y `B1S-PageSize` (GET). Se fijan solas: `B1S-PageSize` = `$top` y `B1S-CaseInsensitive` si hay `$filter`. +- No hay DELETE ni acciones (`Cancel`, `Close`); se agregan por recurso cuando haga falta. +- Las escrituras dejan una línea `AUDIT ` en el log. + +Agregar un recurso nuevo = una línea en `SL_RESOURCES`. El test en vivo (`SAP_LIVE=1 npm run test:e2e`) comprueba que todos existan en el Service Layer del cliente. diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ff01a20..2074b01 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,9 +5,10 @@ import { DatabaseModule } from './database/database.module.js'; import { HealthController } from './health/health.controller.js'; import { PermissionsModule } from './permissions/permissions.module.js'; import { SapModule } from './sap/sap.module.js'; +import { SlModule } from './sl/sl.module.js'; @Module({ - imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule], + imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, SlModule], controllers: [HealthController], }) export class AppModule {} diff --git a/apps/api/src/app.setup.ts b/apps/api/src/app.setup.ts new file mode 100644 index 0000000..bf28219 --- /dev/null +++ b/apps/api/src/app.setup.ts @@ -0,0 +1,8 @@ +import type { NestExpressApplication } from '@nestjs/platform-express'; + +/** Configuración HTTP común a la app real (main.ts) y a los tests e2e. */ +export function configureApp(app: NestExpressApplication): void { + // Los documentos con muchas líneas superan el límite por defecto (100 kb). + app.useBodyParser('json', { limit: '5mb' }); + app.setGlobalPrefix('api'); +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 7108d6d..91f6b4a 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,15 +1,17 @@ import { NestFactory } from '@nestjs/core'; import type { LogLevel } from '@nestjs/common'; +import type { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module.js'; +import { configureApp } from './app.setup.js'; import { AppConfigService } from './config/app-config.service.js'; const LOG_LEVELS: LogLevel[] = ['error', 'warn', 'log', 'debug', 'verbose']; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); const config = app.get(AppConfigService); - app.setGlobalPrefix('api'); + configureApp(app); app.useLogger(LOG_LEVELS.slice(0, LOG_LEVELS.indexOf(config.get('LOG_LEVEL')) + 1)); app.enableCors({ origin: config.get('CORS_ORIGINS') }); app.enableShutdownHooks(); diff --git a/apps/api/src/permissions/path-matcher.spec.ts b/apps/api/src/permissions/path-matcher.spec.ts new file mode 100644 index 0000000..95de853 --- /dev/null +++ b/apps/api/src/permissions/path-matcher.spec.ts @@ -0,0 +1,27 @@ +import { isPathAllowed, matchesPattern } from './path-matcher.js'; + +describe('matchesPattern', () => { + it('"*" permite todo', () => expect(matchesPattern('*', '/lo/que/sea')).toBe(true)); + + it('"/ventas/*" permite la ruta y sus hijas, no prefijos parecidos', () => { + expect(matchesPattern('/ventas/*', '/ventas')).toBe(true); + expect(matchesPattern('/ventas/*', '/ventas/ordenes')).toBe(true); + expect(matchesPattern('/ventas/*', '/ventas/ordenes/mantenimiento')).toBe(true); + expect(matchesPattern('/ventas/*', '/ventas-x')).toBe(false); + expect(matchesPattern('/ventas/*', '/compras/ordenes')).toBe(false); + }); + + it('ruta exacta solo coincide consigo misma', () => { + expect(matchesPattern('/ventas/ordenes', '/ventas/ordenes')).toBe(true); + expect(matchesPattern('/ventas/ordenes', '/ventas/ordenes/x')).toBe(false); + }); +}); + +describe('isPathAllowed', () => { + it('null = cualquier sesión, incluso sin permisos', () => expect(isPathAllowed([], null)).toBe(true)); + it('sin patrones no hay acceso', () => expect(isPathAllowed([], '/ventas/ordenes')).toBe(false)); + it('basta con una de varias rutas requeridas', () => { + expect(isPathAllowed(['/socios/proveedores'], ['/socios/clientes', '/socios/proveedores'])).toBe(true); + expect(isPathAllowed(['/socios/otro'], ['/socios/clientes', '/socios/proveedores'])).toBe(false); + }); +}); diff --git a/apps/api/src/permissions/path-matcher.ts b/apps/api/src/permissions/path-matcher.ts new file mode 100644 index 0000000..8d83f03 --- /dev/null +++ b/apps/api/src/permissions/path-matcher.ts @@ -0,0 +1,16 @@ +/** Patrón de permiso: `*`, `/ventas/*` (la ruta y todo lo que cuelga) o una ruta exacta. */ +export function matchesPattern(pattern: string, path: string): boolean { + if (pattern === '*') return true; + if (pattern.endsWith('/*')) { + const base = pattern.slice(0, -2); + return path === base || path.startsWith(`${base}/`); + } + return pattern === path; +} + +/** `required`: una ruta o varias (basta con una). `null` = cualquier sesión. */ +export function isPathAllowed(patterns: string[], required: string | string[] | null): boolean { + if (required === null) return true; + const paths = Array.isArray(required) ? required : [required]; + return paths.some((path) => patterns.some((pattern) => matchesPattern(pattern, path))); +} diff --git a/apps/api/src/sl/sl-proxy.controller.ts b/apps/api/src/sl/sl-proxy.controller.ts new file mode 100644 index 0000000..84fd5dd --- /dev/null +++ b/apps/api/src/sl/sl-proxy.controller.ts @@ -0,0 +1,63 @@ +import { Body, Controller, Get, Headers, HttpCode, Param, Patch, Post, Query, Req } from '@nestjs/common'; +import type { AuthenticatedRequest } from '../auth/auth.types.js'; +import { ZodValidationPipe } from '../common/zod-validation.pipe.js'; +import { + type GetQuery, + type ListQuery, + bodySchema, + getQuerySchema, + listQuerySchema, + pickSlHeaders, +} from './sl-request.js'; +import { SlProxyService } from './sl-proxy.service.js'; + +type Body = Record; +type IncomingHeaders = Record; + +/** Proxy del Service Layer: `/api/sl/[/]` con parámetros OData del frontend. */ +@Controller('sl') +export class SlProxyController { + constructor(private readonly proxy: SlProxyService) {} + + @Get(':resource') + list( + @Req() req: AuthenticatedRequest, + @Param('resource') resource: string, + @Query(new ZodValidationPipe(listQuerySchema)) query: ListQuery, + @Headers() headers: IncomingHeaders, + ) { + return this.proxy.list(req.user, resource, query, pickSlHeaders(headers, 'GET')); + } + + @Get(':resource/:key') + get( + @Req() req: AuthenticatedRequest, + @Param('resource') resource: string, + @Param('key') key: string, + @Query(new ZodValidationPipe(getQuerySchema)) query: GetQuery, + ) { + return this.proxy.get(req.user, resource, key, query); + } + + @Post(':resource') + create( + @Req() req: AuthenticatedRequest, + @Param('resource') resource: string, + @Body(new ZodValidationPipe(bodySchema)) body: Body, + @Headers() headers: IncomingHeaders, + ) { + return this.proxy.create(req.user, resource, body, pickSlHeaders(headers, 'POST')); + } + + @Patch(':resource/:key') + @HttpCode(204) + async update( + @Req() req: AuthenticatedRequest, + @Param('resource') resource: string, + @Param('key') key: string, + @Body(new ZodValidationPipe(bodySchema)) body: Body, + @Headers() headers: IncomingHeaders, + ): Promise { + await this.proxy.update(req.user, resource, key, body, pickSlHeaders(headers, 'PATCH')); + } +} diff --git a/apps/api/src/sl/sl-proxy.service.ts b/apps/api/src/sl/sl-proxy.service.ts new file mode 100644 index 0000000..96c4a5f --- /dev/null +++ b/apps/api/src/sl/sl-proxy.service.ts @@ -0,0 +1,91 @@ +import { + ForbiddenException, + Injectable, + Logger, + MethodNotAllowedException, + NotFoundException, +} from '@nestjs/common'; +import type { AuthUser } from '../auth/auth.types.js'; +import { isPathAllowed } from '../permissions/path-matcher.js'; +import { PermissionsService } from '../permissions/permissions.service.js'; +import { SapClientService } from '../sap/sap-client.service.js'; +import { + DEFAULT_TOP, + type GetQuery, + type ListQuery, + assertExpandAllowed, + entityPath, +} from './sl-request.js'; +import { SL_RESOURCE_MAP, type SlMethod, type SlResource } from './sl-resources.js'; + +type Headers = Record; + +/** + * Ejecuta contra el Service Layer lo que arma el frontend (parámetros OData y + * cuerpo), pero solo sobre recursos registrados, con permisos de menú y con + * cabeceras B1S-* de lista blanca. El frontend nunca envía la URL. + */ +@Injectable() +export class SlProxyService { + private readonly logger = new Logger(SlProxyService.name); + + constructor( + private readonly sap: SapClientService, + private readonly permissions: PermissionsService, + ) {} + + async list(user: AuthUser, name: string, query: ListQuery, headers: Headers) { + const resource = await this.access(user, name, 'GET'); + assertExpandAllowed(resource, query.$expand); + const top = query.$top ?? DEFAULT_TOP; + const res = await this.sap.request({ + method: 'GET', + path: resource.name, + query: { ...query, $top: top, $inlinecount: 'allpages' }, + headers: { + 'B1S-PageSize': String(top), + ...(query.$filter ? { 'B1S-CaseInsensitive': 'true' } : {}), + ...headers, + }, + }); + return res.data; + } + + async get(user: AuthUser, name: string, key: string, query: GetQuery) { + const resource = await this.access(user, name, 'GET'); + assertExpandAllowed(resource, query.$expand); + const res = await this.sap.request({ method: 'GET', path: entityPath(resource, key), query }); + return res.data; + } + + async create(user: AuthUser, name: string, body: Record, headers: Headers) { + const resource = await this.access(user, name, 'POST'); + this.logger.log(`AUDIT ${user.webUser} POST ${resource.name}`); + const res = await this.sap.request({ method: 'POST', path: resource.name, body, headers }); + return res.data; + } + + async update(user: AuthUser, name: string, key: string, body: Record, headers: Headers) { + const resource = await this.access(user, name, 'PATCH'); + const path = entityPath(resource, key); + this.logger.log(`AUDIT ${user.webUser} PATCH ${path}`); + await this.sap.request({ method: 'PATCH', path, body, headers }); + } + + private async access(user: AuthUser, name: string, method: SlMethod): Promise { + const resource = SL_RESOURCE_MAP.get(name); + if (!resource) throw new NotFoundException(`Recurso no disponible: ${name}`); + if (!resource.methods.includes(method)) { + throw new MethodNotAllowedException(`${method} no está permitido para ${resource.name}`); + } + const required = method === 'GET' ? resource.read : resource.write; + if (required !== null) { + const patterns = await this.permissions.getPaths(user.webUser); + if (!isPathAllowed(patterns, required)) { + this.logger.warn(`${user.webUser} sin permiso para ${method} ${resource.name}`); + throw new ForbiddenException(`No tiene permisos para ${method === 'GET' ? 'consultar' : 'modificar'} ${resource.name}`); + } + } + return resource; + } +} diff --git a/apps/api/src/sl/sl-request.spec.ts b/apps/api/src/sl/sl-request.spec.ts new file mode 100644 index 0000000..4512405 --- /dev/null +++ b/apps/api/src/sl/sl-request.spec.ts @@ -0,0 +1,76 @@ +import { BadRequestException } from '@nestjs/common'; +import { SL_RESOURCES, SL_RESOURCE_MAP } from './sl-resources.js'; +import { entityPath, getQuerySchema, listQuerySchema, pickSlHeaders } from './sl-request.js'; + +describe('registro de recursos', () => { + it('no tiene nombres repetidos ni expone EmployeesInfo', () => { + expect(new Set(SL_RESOURCES.map((r) => r.name)).size).toBe(SL_RESOURCES.length); + expect(SL_RESOURCE_MAP.has('EmployeesInfo')).toBe(false); + }); + + it('todo recurso con escritura declara qué ruta de menú la gobierna', () => { + for (const r of SL_RESOURCES.filter((x) => x.methods.some((m) => m !== 'GET'))) { + expect([r.write].flat().length, r.name).toBeGreaterThan(0); + } + }); + + it('incluye los recursos pedidos', () => { + for (const n of ['Quotations', 'Orders', 'PurchaseOrders', 'StockTransfers', 'InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests']) { + expect(SL_RESOURCE_MAP.has(n), n).toBe(true); + } + }); +}); + +describe('listQuerySchema', () => { + it('normaliza $select y convierte $top/$skip', () => { + const q = listQuerySchema.parse({ $select: 'ItemCode, ItemName', $top: '20', $skip: '40', $orderby: 'ItemCode desc' }); + expect(q).toEqual({ $select: 'ItemCode,ItemName', $top: 20, $skip: 40, $orderby: 'ItemCode desc' }); + }); + + it('rechaza parámetros desconocidos, $top excesivo y campos con símbolos', () => { + expect(listQuerySchema.safeParse({ $apply: 'x' }).success).toBe(false); + expect(listQuerySchema.safeParse({ foo: 'x' }).success).toBe(false); + expect(listQuerySchema.safeParse({ $top: '100000' }).success).toBe(false); + expect(listQuerySchema.safeParse({ $select: 'A;DROP' }).success).toBe(false); + expect(listQuerySchema.safeParse({ $select: ['A', 'B'] }).success).toBe(false); + }); + + it('getQuerySchema solo admite $select y $expand', () => { + expect(getQuerySchema.safeParse({ $filter: "a eq 'b'" }).success).toBe(false); + }); +}); + +describe('entityPath', () => { + const orders = SL_RESOURCE_MAP.get('Orders')!; + const items = SL_RESOURCE_MAP.get('Items')!; + + it('clave numérica', () => { + expect(entityPath(orders, '123')).toBe('Orders(123)'); + for (const bad of ['1)/Cancel', '../Logout', 'abc', '', '12345678901']) { + expect(() => entityPath(orders, bad), bad).toThrow(BadRequestException); + } + }); + + it('clave de texto: escapa comillas y codifica la ruta', () => { + expect(entityPath(items, 'A001')).toBe("Items('A001')"); + expect(entityPath(items, "O'NEIL")).toBe("Items('O''NEIL')"); + expect(entityPath(items, "x')/Delete('y")).toBe("Items('x'')%2FDelete(''y')"); + expect(entityPath(items, "x')/Delete('y")).not.toContain('/Delete'); + expect(() => entityPath(items, 'a\nb')).toThrow(BadRequestException); + }); +}); + +describe('pickSlHeaders', () => { + it('deja pasar solo las B1S-* permitidas para el método', () => { + expect(pickSlHeaders({ 'b1s-replacecollectionsonpatch': 'true', host: 'x', cookie: 'y' }, 'PATCH')).toEqual({ + 'B1S-ReplaceCollectionsOnPatch': 'true', + }); + expect(pickSlHeaders({ 'b1s-pagesize': '100' }, 'GET')).toEqual({ 'B1S-PageSize': '100' }); + }); + + it('rechaza B1S-* desconocidas, de otro método o con valor inválido', () => { + expect(() => pickSlHeaders({ 'b1s-lo-que-sea': 'x' }, 'GET')).toThrow(BadRequestException); + expect(() => pickSlHeaders({ 'b1s-replacecollectionsonpatch': 'true' }, 'GET')).toThrow(BadRequestException); + expect(() => pickSlHeaders({ 'b1s-pagesize': '99999' }, 'GET')).toThrow(BadRequestException); + }); +}); diff --git a/apps/api/src/sl/sl-request.ts b/apps/api/src/sl/sl-request.ts new file mode 100644 index 0000000..3bcf431 --- /dev/null +++ b/apps/api/src/sl/sl-request.ts @@ -0,0 +1,100 @@ +import { BadRequestException } from '@nestjs/common'; +import { z } from 'zod'; +import type { SlResource } from './sl-resources.js'; + +export const MAX_TOP = 500; +export const DEFAULT_TOP = 50; + +const noControlChars = (s: string) => { + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + if (code < 32 || code === 127) return false; + } + return true; +}; +const ident = '[A-Za-z0-9_]+'; + +const select = z + .string() + .max(1000) + .regex(new RegExp(`^${ident}(\\s*,\\s*${ident})*$`), 'Lista de campos inválida') + .transform((s) => s.replace(/\s+/g, '')); +const expand = z + .string() + .max(200) + .regex(new RegExp(`^${ident}(\\s*,\\s*${ident})*$`), 'Lista de colecciones inválida') + .transform((s) => s.replace(/\s+/g, '')); +const orderItem = `${ident}(\\s+(asc|desc))?`; +const orderby = z + .string() + .max(300) + .regex(new RegExp(`^${orderItem}(\\s*,\\s*${orderItem})*$`, 'i'), 'Orden inválido'); + +/** Parámetros OData aceptados al listar. Cualquier otro se rechaza. */ +export const listQuerySchema = z + .object({ + $select: select.optional(), + $filter: z.string().max(2000).refine(noControlChars, 'Filtro inválido').optional(), + $orderby: orderby.optional(), + $top: z.coerce.number().int().min(1).max(MAX_TOP).optional(), + $skip: z.coerce.number().int().min(0).optional(), + $expand: expand.optional(), + }) + .strict(); +export type ListQuery = z.infer; + +/** Parámetros OData aceptados al leer un registro por clave. */ +export const getQuerySchema = z.object({ $select: select.optional(), $expand: expand.optional() }).strict(); +export type GetQuery = z.infer; + +export const bodySchema = z.record(z.string(), z.unknown()); + +/** Cabeceras B1S-* que el frontend puede decidir; cualquier otra B1S-* se rechaza. */ +const B1S_HEADERS: Record boolean }> = { + 'b1s-replacecollectionsonpatch': { + name: 'B1S-ReplaceCollectionsOnPatch', + methods: ['PATCH'], + valid: (v) => v === 'true' || v === 'false', + }, + 'b1s-caseinsensitive': { name: 'B1S-CaseInsensitive', methods: ['GET'], valid: (v) => v === 'true' || v === 'false' }, + 'b1s-pagesize': { + name: 'B1S-PageSize', + methods: ['GET'], + valid: (v) => /^\d+$/.test(v) && Number(v) >= 1 && Number(v) <= MAX_TOP, + }, +}; + +export function pickSlHeaders( + incoming: Record, + method: string, +): Record { + const out: Record = {}; + for (const [rawName, value] of Object.entries(incoming)) { + if (!rawName.startsWith('b1s-')) continue; + const rule = B1S_HEADERS[rawName]; + if (!rule || !rule.methods.includes(method) || typeof value !== 'string' || !rule.valid(value.trim())) { + throw new BadRequestException(`Cabecera no permitida o inválida: ${rawName}`); + } + out[rule.name] = value.trim(); + } + return out; +} + +/** Arma `Entidad(clave)` validando y escapando la clave según su tipo. */ +export function entityPath(resource: SlResource, key: string): string { + if (resource.key === 'number') { + if (!/^\d{1,10}$/.test(key)) throw new BadRequestException(`Clave inválida para ${resource.name}`); + return `${resource.name}(${key})`; + } + if (key.length < 1 || key.length > 100 || !noControlChars(key)) { + throw new BadRequestException(`Clave inválida para ${resource.name}`); + } + return `${resource.name}('${encodeURIComponent(key.replace(/'/g, "''"))}')`; +} + +export function assertExpandAllowed(resource: SlResource, expandParam: string | undefined): void { + if (!expandParam) return; + const allowed = new Set(resource.expand ?? []); + const rejected = expandParam.split(',').filter((e) => !allowed.has(e)); + if (rejected.length) throw new BadRequestException(`$expand no permitido para ${resource.name}: ${rejected.join(', ')}`); +} diff --git a/apps/api/src/sl/sl-resources.ts b/apps/api/src/sl/sl-resources.ts new file mode 100644 index 0000000..b2235ed --- /dev/null +++ b/apps/api/src/sl/sl-resources.ts @@ -0,0 +1,79 @@ +export type SlMethod = 'GET' | 'POST' | 'PATCH'; + +/** Ruta(s) de menú que el usuario debe tener permitidas (basta con una). */ +export type RequiredPath = string | string[]; + +export interface SlResource { + /** Nombre de la entidad en el Service Layer (sensible a mayúsculas). */ + name: string; + /** Tipo de la clave primaria: se valida y se escapa al armar `Entidad(clave)`. */ + key: 'number' | 'string'; + methods: SlMethod[]; + /** Ruta de menú requerida para leer; `null` = cualquier sesión (catálogos de consulta). */ + read: RequiredPath | null; + /** Ruta de menú requerida para crear/modificar. */ + write: RequiredPath; + /** Colecciones permitidas en `$expand` (por defecto ninguna). */ + expand?: string[]; +} + +const document = (name: string, path: string): SlResource => ({ + name, + key: 'number', + methods: ['GET', 'POST', 'PATCH'], + read: path, + write: path, +}); + +const catalog = (name: string, key: 'number' | 'string', writePath?: RequiredPath): SlResource => ({ + name, + key, + methods: writePath ? ['GET', 'POST', 'PATCH'] : ['GET'], + read: null, + write: writePath ?? [], +}); + +/** + * Único lugar donde se declara qué puede tocar el frontend. Lo que no esté aquí + * no se puede consultar (404). Nunca registrar `EmployeesInfo`: contiene U_WEB_PASS. + * + * Las rutas de menú son las mismas que usará el frontend (permisos por pantalla). + */ +export const SL_RESOURCES: readonly SlResource[] = [ + // Ventas + document('Quotations', '/ventas/ofertas'), + document('Orders', '/ventas/ordenes'), + document('DeliveryNotes', '/ventas/entrega'), + document('Invoices', '/ventas/facturas'), + document('Returns', '/ventas/devoluciones'), + document('CreditNotes', '/ventas/notas-credito'), + + // Compras + document('PurchaseRequests', '/compras/solicitudes'), + document('PurchaseQuotations', '/compras/ofertas'), + document('PurchaseOrders', '/compras/ordenes'), + document('PurchaseDeliveryNotes', '/compras/recepcion'), + document('PurchaseInvoices', '/compras/facturas'), + document('PurchaseReturns', '/compras/devoluciones'), + document('PurchaseCreditNotes', '/compras/notas-credito'), + + // Inventario + document('StockTransfers', '/inventario/transferencias'), + document('InventoryTransferRequests', '/inventario/solicitudes-traslado'), + document('InventoryGenEntries', '/inventario/entradas'), + document('InventoryGenExits', '/inventario/salidas'), + + // Maestros (consulta para cualquier sesión; escritura según el módulo) + catalog('BusinessPartners', 'string', ['/socios/clientes', '/socios/proveedores']), + catalog('Items', 'string', '/inventario/articulos'), + catalog('Warehouses', 'string'), + catalog('Currencies', 'string'), + catalog('SalesTaxCodes', 'string'), + catalog('VatGroups', 'string'), + catalog('PaymentTermsTypes', 'number'), + catalog('SalesPersons', 'number'), + catalog('PriceLists', 'number'), + catalog('UnitOfMeasurements', 'number'), +]; + +export const SL_RESOURCE_MAP: ReadonlyMap = new Map(SL_RESOURCES.map((r) => [r.name, r])); diff --git a/apps/api/src/sl/sl.module.ts b/apps/api/src/sl/sl.module.ts new file mode 100644 index 0000000..5bf66fe --- /dev/null +++ b/apps/api/src/sl/sl.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PermissionsModule } from '../permissions/permissions.module.js'; +import { SapModule } from '../sap/sap.module.js'; +import { SlProxyController } from './sl-proxy.controller.js'; +import { SlProxyService } from './sl-proxy.service.js'; + +@Module({ + imports: [SapModule, PermissionsModule], + controllers: [SlProxyController], + providers: [SlProxyService], +}) +export class SlModule {} diff --git a/apps/api/test/sap-live.e2e-spec.ts b/apps/api/test/sap-live.e2e-spec.ts index 059021d..948f39b 100644 --- a/apps/api/test/sap-live.e2e-spec.ts +++ b/apps/api/test/sap-live.e2e-spec.ts @@ -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); }); diff --git a/apps/api/test/sl-proxy.e2e-spec.ts b/apps/api/test/sl-proxy.e2e-spec.ts new file mode 100644 index 0000000..255a393 --- /dev/null +++ b/apps/api/test/sl-proxy.e2e-spec.ts @@ -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); + }); + }); +}); diff --git a/apps/api/test/support/create-test-app.ts b/apps/api/test/support/create-test-app.ts index f0f04ae..1c5a848 100644 --- a/apps/api/test/support/create-test-app.ts +++ b/apps/api/test/support/create-test-app.ts @@ -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; + body?: unknown; + headers?: Record; +} + +/** 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 { +export async function createTestApp( + employees: FakeEmployee[], + adminWebUsers = '', + onSapRequest: (req: FakeSapRequest) => unknown = () => ({}), +): Promise { + 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(); + configureApp(app); await app.init(); return app; }