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:
1 parent
73fffee2f7
commit
c0990ca83e
15 files changed
+683
-6
No files matched your search
@@ -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 <usuario> <método> <recurso>` 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.
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
@@ -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]));
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user