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

+2 -1
View File
@@ -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 {}
+8
View File
@@ -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');
}
+4 -2
View File
@@ -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<NestExpressApplication>(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();
@@ -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);
});
});
+16
View File
@@ -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)));
}
+63
View File
@@ -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<string, unknown>;
type IncomingHeaders = Record<string, string | string[] | undefined>;
/** Proxy del Service Layer: `/api/sl/<Recurso>[/<clave>]` 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<void> {
await this.proxy.update(req.user, resource, key, body, pickSlHeaders(headers, 'PATCH'));
}
}
+91
View File
@@ -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<string, string>;
/**
* 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<string, unknown>, 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<string, unknown>, 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<SlResource> {
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;
}
}
+76
View File
@@ -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);
});
});
+100
View File
@@ -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<typeof listQuerySchema>;
/** 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<typeof getQuerySchema>;
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<string, { name: string; methods: string[]; valid: (v: string) => 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<string, string | string[] | undefined>,
method: string,
): Record<string, string> {
const out: Record<string, string> = {};
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(', ')}`);
}
+79
View File
@@ -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<string, SlResource> = new Map(SL_RESOURCES.map((r) => [r.name, r]));
+12
View File
@@ -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 {}