From bb4acb551049c14669b71dd006983de0becfdc56 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 13 Oct 2026 18:29:13 -0500 Subject: [PATCH] =?UTF-8?q?feat(api):=20capa=20SAP=20con=20sesi=C3=B3n=20c?= =?UTF-8?q?ompartida,=20mutex=20de=20login=20y=20cola=20de=20peticiones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SapSessionService: una sesión para todos; un solo login aunque lleguen peticiones concurrentes o 401 simultáneos (loginInFlight); logout al apagar. - SapClientService: proxy con reintento único ante 401, errores SAP traducidos (SapHttpException) y fallos de red/TLS mapeados a 502/504. Un login rechazado se informa como 502, nunca 401 (el frontend lo toma como sesión web expirada). - Confianza TLS: SAPB1_SL_CA_FILE opcional (autofirmado de SAP o CA de firewall) sumado a las CA del sistema; VERIFY_SSL=true por defecto y no se puede apagar en producción. - SAPB1_SL_MAX_CONCURRENCY (default 1): medido en SAP10, más de ~2 peticiones en paralelo retrasan ~7 s cada una extra; en serie ~60 ms. - GET /api/health/sap, tests unitarios (MockAgent) y e2e opcional contra SAP real (SAP_LIVE=1). apps/api/certs documentado; *.pem/*.cer ignorados. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 5 + apps/api/.env.example | 22 ++++ apps/api/certs/README.md | 23 ++++ apps/api/package.json | 1 + apps/api/src/app.module.ts | 3 +- apps/api/src/config/env.schema.spec.ts | 39 +++++- apps/api/src/config/env.schema.ts | 71 +++++++--- apps/api/src/health/health.controller.ts | 14 ++ apps/api/src/sap/sap-client.service.spec.ts | 99 ++++++++++++++ apps/api/src/sap/sap-client.service.ts | 94 ++++++++++++++ apps/api/src/sap/sap-dispatcher.provider.ts | 29 +++++ apps/api/src/sap/sap-errors.ts | 50 ++++++++ apps/api/src/sap/sap-http.exception.ts | 12 ++ apps/api/src/sap/sap-session.service.ts | 135 ++++++++++++++++++++ apps/api/src/sap/sap.constants.ts | 2 + apps/api/src/sap/sap.module.ts | 10 ++ apps/api/test/health.e2e-spec.ts | 8 +- apps/api/test/sap-live.e2e-spec.ts | 29 +++++ package-lock.json | 5 +- 19 files changed, 629 insertions(+), 22 deletions(-) create mode 100644 apps/api/certs/README.md create mode 100644 apps/api/src/sap/sap-client.service.spec.ts create mode 100644 apps/api/src/sap/sap-client.service.ts create mode 100644 apps/api/src/sap/sap-dispatcher.provider.ts create mode 100644 apps/api/src/sap/sap-errors.ts create mode 100644 apps/api/src/sap/sap-http.exception.ts create mode 100644 apps/api/src/sap/sap-session.service.ts create mode 100644 apps/api/src/sap/sap.constants.ts create mode 100644 apps/api/src/sap/sap.module.ts create mode 100644 apps/api/test/sap-live.e2e-spec.ts diff --git a/.gitignore b/.gitignore index 525aeca..859d7f6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ logs/ .vscode/* !.vscode/extensions.json *.tsbuildinfo + +# Certificados de confianza por cliente (ver apps/api/certs/README.md) +apps/api/certs/*.pem +apps/api/certs/*.cer +apps/api/certs/*.crt diff --git a/apps/api/.env.example b/apps/api/.env.example index c4e3c5b..cb986d3 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -2,4 +2,26 @@ NODE_ENV=development PORT=3000 # Orígenes permitidos por CORS, separados por coma (ej. https://erp.local) CORS_ORIGINS=http://localhost:4200 +# error | warn | log | debug | verbose LOG_LEVEL=log + +# --- SAP Business One Service Layer --- +SAPB1_SL_URL=https://servidor-sap:50000/b1s/v1 +SAPB1_SL_COMPANYDB= +# Usuario de servicio compartido (licencia SAP) +SAPB1_SL_USER= +SAPB1_SL_PASSWORD= +SAPB1_SL_TIMEOUT_MS=15000 +# Peticiones simultáneas hacia el Service Layer (el resto se encola). Con más de +# ~2 en paralelo el Service Layer de prueba retrasaba ~7 s cada petición extra. +SAPB1_SL_MAX_CONCURRENCY=1 + +# Confianza TLS (elegir según el caso): +# 1) Certificado válido de una CA pública -> dejar vacío. +# 2) Certificado autofirmado del Service Layer, o CA de un firewall que +# inspecciona SSL (ej. Fortinet) -> ruta a un PEM, relativa a apps/api. +# Para el autofirmado de SAP: +# echo | openssl s_client -connect HOST:50000 -servername HOST | openssl x509 -out certs/sap.pem +# 3) Solo pruebas locales: SAPB1_SL_VERIFY_SSL=false (rechazado si NODE_ENV=production). +SAPB1_SL_CA_FILE= +SAPB1_SL_VERIFY_SSL=true diff --git a/apps/api/certs/README.md b/apps/api/certs/README.md new file mode 100644 index 0000000..d1ecc0d --- /dev/null +++ b/apps/api/certs/README.md @@ -0,0 +1,23 @@ +# Certificados de confianza (opcional) + +Aquí van los certificados **PEM** que el backend debe aceptar al conectarse al Service Layer. +Se referencian con `SAPB1_SL_CA_FILE` en `.env` (ruta relativa a `apps/api`). + +| Situación | Qué cargar | +|---|---| +| Service Layer con certificado autofirmado de SAP (lo habitual) | El propio certificado del servidor (ver abajo) | +| Red que inspecciona SSL (Fortinet, Zscaler…) | La CA de ese firewall | +| Certificado de una CA pública | Nada; dejar `SAPB1_SL_CA_FILE` vacío | + +Extraer el certificado autofirmado del Service Layer: + +```bash +echo | openssl s_client -connect HOST:50000 -servername HOST | openssl x509 -out certs/sap.pem +``` + +Verifique la huella (`openssl x509 -in certs/sap.pem -noout -fingerprint -sha256`) contra el servidor +antes de confiar en él. Si el certificado está en DER, conviértalo: +`openssl x509 -inform der -in cert.cer -out cert.pem`. + +Los `*.pem`, `*.cer` y `*.crt` no se versionan en la base (son específicos de cada cliente). +Cada rama `cliente/` puede quitar esa regla de `.gitignore` y versionar los suyos. diff --git a/apps/api/package.json b/apps/api/package.json index c9af63a..fc203e7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -28,6 +28,7 @@ "@nestjs/platform-express": "^12.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "undici": "^6.20.1", "zod": "^4.6.7" }, "devDependencies": { diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a81aa15..197b75c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { AppConfigModule } from './config/config.module.js'; import { HealthController } from './health/health.controller.js'; +import { SapModule } from './sap/sap.module.js'; @Module({ - imports: [AppConfigModule], + imports: [AppConfigModule, SapModule], controllers: [HealthController], }) export class AppModule {} diff --git a/apps/api/src/config/env.schema.spec.ts b/apps/api/src/config/env.schema.spec.ts index 7623bcc..f55399d 100644 --- a/apps/api/src/config/env.schema.spec.ts +++ b/apps/api/src/config/env.schema.spec.ts @@ -1,20 +1,53 @@ import { validateEnv } from './env.schema.js'; +const sap = { + SAPB1_SL_URL: 'https://sap.local:50000/b1s/v1/', + SAPB1_SL_COMPANYDB: 'DB', + SAPB1_SL_USER: 'u', + SAPB1_SL_PASSWORD: 'p', +}; + describe('validateEnv', () => { it('aplica valores por defecto', () => { - const env = validateEnv({}); + const env = validateEnv(sap); expect(env.PORT).toBe(3000); expect(env.CORS_ORIGINS).toEqual([]); expect(env.LOG_LEVEL).toBe('log'); + expect(env.SAPB1_SL_VERIFY_SSL).toBe(true); + expect(env.SAPB1_SL_CA_FILE).toBeUndefined(); + expect(env.SAPB1_SL_MAX_CONCURRENCY).toBe(1); + }); + + it('quita la barra final de la URL de SAP', () => { + expect(validateEnv(sap).SAPB1_SL_URL).toBe('https://sap.local:50000/b1s/v1'); }); it('convierte PORT y separa CORS_ORIGINS', () => { - const env = validateEnv({ PORT: '8080', CORS_ORIGINS: 'https://a.local, https://b.local' }); + const env = validateEnv({ ...sap, PORT: '8080', CORS_ORIGINS: 'https://a.local, https://b.local' }); expect(env.PORT).toBe(8080); expect(env.CORS_ORIGINS).toEqual(['https://a.local', 'https://b.local']); }); it('rechaza valores inválidos con un mensaje claro', () => { - expect(() => validateEnv({ PORT: 'abc' })).toThrow(/PORT/); + expect(() => validateEnv({ ...sap, PORT: 'abc' })).toThrow(/PORT/); + }); + + it('exige las credenciales de SAP', () => { + expect(() => validateEnv({})).toThrow(/SAPB1_SL_URL/); + }); + + it('trata SAPB1_SL_CA_FILE vacío como no configurado', () => { + expect(validateEnv({ ...sap, SAPB1_SL_CA_FILE: '' }).SAPB1_SL_CA_FILE).toBeUndefined(); + }); + + it('rechaza un SAPB1_SL_CA_FILE que no existe', () => { + expect(() => validateEnv({ ...sap, SAPB1_SL_CA_FILE: './no-existe.pem' })).toThrow(/CA_FILE/); + }); + + it('no permite desactivar la validación SSL en producción', () => { + expect(() => + validateEnv({ ...sap, NODE_ENV: 'production', SAPB1_SL_VERIFY_SSL: 'false' }), + ).toThrow(/producción/); + expect(validateEnv({ ...sap, SAPB1_SL_VERIFY_SSL: 'false' }).SAPB1_SL_VERIFY_SSL).toBe(false); }); }); diff --git a/apps/api/src/config/env.schema.ts b/apps/api/src/config/env.schema.ts index 6f8d664..f04d963 100644 --- a/apps/api/src/config/env.schema.ts +++ b/apps/api/src/config/env.schema.ts @@ -1,20 +1,61 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; import { z } from 'zod'; -export const envSchema = z.object({ - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - PORT: z.coerce.number().int().positive().default(3000), - // Orígenes permitidos por CORS, separados por coma. Vacío = mismo origen. - CORS_ORIGINS: z - .string() - .default('') - .transform((v) => - v - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - ), - LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug', 'verbose']).default('log'), -}); +const emptyToUndefined = (v: unknown) => (v === '' ? undefined : v); + +export const envSchema = z + .object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.coerce.number().int().positive().default(3000), + // Orígenes permitidos por CORS, separados por coma. Vacío = mismo origen. + CORS_ORIGINS: z + .string() + .default('') + .transform((v) => + v + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ), + LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug', 'verbose']).default('log'), + + // --- SAP Business One Service Layer --- + SAPB1_SL_URL: z + .url() + .transform((v) => v.replace(/\/+$/, '')), + SAPB1_SL_COMPANYDB: z.string().min(1), + SAPB1_SL_USER: z.string().min(1), + SAPB1_SL_PASSWORD: z.string().min(1), + SAPB1_SL_TIMEOUT_MS: z.coerce.number().int().positive().default(15000), + // Peticiones simultáneas permitidas hacia el Service Layer; el resto se encola. + // Medido en SAP10: con más de ~2 en paralelo el Service Layer retrasa ~7 s cada + // petición extra, mientras que en serie cada una tarda ~60 ms. Default: 1. + SAPB1_SL_MAX_CONCURRENCY: z.coerce.number().int().positive().default(1), + // CA adicional de confianza (PEM): certificado autofirmado del Service + // Layer o CA de un firewall que inspecciona SSL. Opcional. + SAPB1_SL_CA_FILE: z.preprocess(emptyToUndefined, z.string().optional()), + SAPB1_SL_VERIFY_SSL: z + .enum(['true', 'false']) + .default('true') + .transform((v) => v === 'true'), + }) + .superRefine((env, ctx) => { + if (env.SAPB1_SL_CA_FILE && !existsSync(resolve(env.SAPB1_SL_CA_FILE))) { + ctx.addIssue({ + code: 'custom', + path: ['SAPB1_SL_CA_FILE'], + message: `no existe el archivo ${resolve(env.SAPB1_SL_CA_FILE)}`, + }); + } + if (env.NODE_ENV === 'production' && !env.SAPB1_SL_VERIFY_SSL) { + ctx.addIssue({ + code: 'custom', + path: ['SAPB1_SL_VERIFY_SSL'], + message: 'no se permite desactivar la validación SSL en producción; use SAPB1_SL_CA_FILE', + }); + } + }); export type Env = z.infer; diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index 1fa4b0a..2c27631 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -1,9 +1,23 @@ import { Controller, Get } from '@nestjs/common'; +import { AppConfigService } from '../config/app-config.service.js'; +import { SapSessionService } from '../sap/sap-session.service.js'; @Controller('health') export class HealthController { + constructor( + private readonly sessions: SapSessionService, + private readonly config: AppConfigService, + ) {} + @Get() check() { return { status: 'ok', uptime: Math.round(process.uptime()) }; } + + // TODO(auth): proteger con el guard de sesión cuando exista el login. + @Get('sap') + async sap() { + await this.sessions.getSession(); + return { companyDb: this.config.get('SAPB1_SL_COMPANYDB'), ...this.sessions.status() }; + } } diff --git a/apps/api/src/sap/sap-client.service.spec.ts b/apps/api/src/sap/sap-client.service.spec.ts new file mode 100644 index 0000000..b981961 --- /dev/null +++ b/apps/api/src/sap/sap-client.service.spec.ts @@ -0,0 +1,99 @@ +import { MockAgent } from 'undici'; +import type { AppConfigService } from '../config/app-config.service.js'; +import { SapClientService, buildQuery } from './sap-client.service.js'; +import { SapHttpException } from './sap-http.exception.js'; +import { SapSessionService } from './sap-session.service.js'; + +const ORIGIN = 'https://sap.test:50000'; +const settings: Record = { + SAPB1_SL_URL: `${ORIGIN}/b1s/v1`, + SAPB1_SL_COMPANYDB: 'DB', + SAPB1_SL_USER: 'u', + SAPB1_SL_PASSWORD: 'p', + SAPB1_SL_TIMEOUT_MS: 2000, +}; +const config = { get: (k: string) => settings[k] } as unknown as AppConfigService; + +const loginReply = (id: string) => ({ + statusCode: 200, + data: { SessionId: id }, + responseOptions: { headers: { 'set-cookie': [`B1SESSION=${id}; path=/; HttpOnly`, 'ROUTEID=.n1; path=/'] } }, +}); +const cookieFor = (id: string) => `B1SESSION=${id}; ROUTEID=.n1`; + +describe('SapClientService', () => { + let agent: MockAgent; + let pool: ReturnType; + let client: SapClientService; + + beforeEach(() => { + agent = new MockAgent(); + agent.disableNetConnect(); + pool = agent.get(ORIGIN); + const sessions = new SapSessionService(config, agent); + client = new SapClientService(config, sessions, agent); + }); + + afterEach(async () => { + await agent.close(); + }); + + const items = (cookie: string) => ({ + path: (p: string) => p.startsWith('/b1s/v1/Items'), + method: 'GET' as const, + headers: { cookie }, + }); + + it('hace UN solo login con peticiones concurrentes y reutiliza la sesión', async () => { + pool.intercept({ path: '/b1s/v1/Login', method: 'POST' }).reply(() => loginReply('S1')); + pool.intercept(items(cookieFor('S1'))).reply(200, { value: [] }).times(5); + + const results = await Promise.all(Array.from({ length: 5 }, () => client.get('Items', { $top: 1 }))); + + expect(results.map((r) => r.status)).toEqual([200, 200, 200, 200, 200]); + agent.assertNoPendingInterceptors(); + }); + + it('ante 401 concurrentes renueva la sesión una sola vez y reintenta', async () => { + pool.intercept({ path: '/b1s/v1/Login', method: 'POST' }).reply(() => loginReply('S1')); + pool.intercept(items(cookieFor('S1'))).reply(401, { error: { code: 301 } }).times(3); + pool.intercept({ path: '/b1s/v1/Login', method: 'POST' }).reply(() => loginReply('S2')); + pool.intercept(items(cookieFor('S2'))).reply(200, { value: [{ ItemCode: 'A' }] }).times(3); + + const results = await Promise.all([1, 2, 3].map(() => client.get('Items'))); + + expect(results.every((r) => r.status === 200)).toBe(true); + // Un tercer login no tendría interceptor y fallaría (disableNetConnect). + agent.assertNoPendingInterceptors(); + }); + + it('traduce el error de SAP conservando estado, mensaje y código', async () => { + pool.intercept({ path: '/b1s/v1/Login', method: 'POST' }).reply(() => loginReply('S1')); + pool + .intercept({ path: '/b1s/v1/Orders', method: 'POST' }) + .reply(400, { error: { code: -5002, message: { lang: 'en-us', value: 'Falta el socio de negocio' } } }); + + const err = await client.request({ method: 'POST', path: 'Orders', body: {} }).catch((e) => e); + + expect(err).toBeInstanceOf(SapHttpException); + expect(err.getStatus()).toBe(400); + expect(err.getResponse()).toMatchObject({ message: 'Falta el socio de negocio', sapCode: -5002 }); + }); + + it('un login rechazado se informa como 502 (nunca 401, que cerraría la sesión web)', async () => { + pool.intercept({ path: '/b1s/v1/Login', method: 'POST' }).reply(401, { error: { code: -304 } }); + + const err = await client.get('Items').catch((e) => e); + + expect(err.getStatus()).toBe(502); + }); +}); + +describe('buildQuery', () => { + it('codifica valores, conserva las claves $ y omite vacíos', () => { + expect(buildQuery({ $filter: "contains(CardName,'A B')", $top: 5, $select: undefined, x: '' })).toBe( + "?$filter=contains(CardName%2C'A%20B')&$top=5", + ); + expect(buildQuery()).toBe(''); + }); +}); diff --git a/apps/api/src/sap/sap-client.service.ts b/apps/api/src/sap/sap-client.service.ts new file mode 100644 index 0000000..aed3adc --- /dev/null +++ b/apps/api/src/sap/sap-client.service.ts @@ -0,0 +1,94 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { type Dispatcher, request } from 'undici'; +import { AppConfigService } from '../config/app-config.service.js'; +import { SAP_DISPATCHER } from './sap.constants.js'; +import { describeConnectionError, toSapException } from './sap-errors.js'; +import { type SapSession, SapSessionService } from './sap-session.service.js'; + +export type SapQuery = Record; + +export interface SapRequest { + method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + /** Ruta relativa al Service Layer, ej. `Items('A001')` o `Orders`. */ + path: string; + query?: SapQuery; + body?: unknown; + /** Cabeceras B1S-* u otras que decide quien llama. */ + headers?: Record; +} + +export interface SapResponse { + status: number; + headers: Dispatcher.ResponseData['headers']; + data: T; +} + +/** Proxy centralizado hacia el Service Layer con reautenticación transparente. */ +@Injectable() +export class SapClientService { + private readonly logger = new Logger(SapClientService.name); + + constructor( + private readonly config: AppConfigService, + private readonly sessions: SapSessionService, + @Inject(SAP_DISPATCHER) private readonly dispatcher: Dispatcher, + ) {} + + async request(req: SapRequest): Promise> { + let session = await this.sessions.getSession(); + let res = await this.send(session, req); + + if (res.status === 401) { + this.logger.warn(`Sesión SAP expirada (401) en ${req.method} ${req.path}; reautenticando`); + session = await this.sessions.renew(session); + res = await this.send(session, req); + } + + if (res.status >= 400) throw toSapException(res.status, res.text); + return { + status: res.status, + headers: res.headers, + data: (res.text ? JSON.parse(res.text) : undefined) as T, + }; + } + + get(path: string, query?: SapQuery, headers?: Record) { + return this.request({ method: 'GET', path, query, headers }); + } + + private async send(session: SapSession, req: SapRequest) { + const timeout = this.config.get('SAPB1_SL_TIMEOUT_MS'); + const url = `${this.config.get('SAPB1_SL_URL')}/${req.path}${buildQuery(req.query)}`; + const started = Date.now(); + try { + const res = await request(url, { + method: req.method, + dispatcher: this.dispatcher, + headers: { + 'content-type': 'application/json', + accept: 'application/json', + ...req.headers, + cookie: session.cookie, + }, + body: req.body === undefined ? undefined : JSON.stringify(req.body), + headersTimeout: timeout, + bodyTimeout: timeout, + }); + const text = await res.body.text(); + this.logger.debug(`${req.method} ${req.path} -> ${res.statusCode} (${Date.now() - started} ms)`); + return { status: res.statusCode, headers: res.headers, text }; + } catch (err) { + this.logger.error(`${req.method} ${req.path}: ${(err as Error).message}`); + throw describeConnectionError(err); + } + } +} + +/** Codifica los valores (no las claves `$filter`, `$top`...) con %20 para espacios. */ +export function buildQuery(query?: SapQuery): string { + if (!query) return ''; + const parts = Object.entries(query) + .filter(([, v]) => v !== undefined && v !== '') + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`); + return parts.length ? `?${parts.join('&')}` : ''; +} diff --git a/apps/api/src/sap/sap-dispatcher.provider.ts b/apps/api/src/sap/sap-dispatcher.provider.ts new file mode 100644 index 0000000..98b7529 --- /dev/null +++ b/apps/api/src/sap/sap-dispatcher.provider.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { rootCertificates } from 'node:tls'; +import type { Provider } from '@nestjs/common'; +import { Agent } from 'undici'; +import { AppConfigService } from '../config/app-config.service.js'; +import { SAP_DISPATCHER } from './sap.constants.js'; + +/** + * Cliente HTTP compartido hacia el Service Layer: limita las conexiones + * simultáneas y fija la confianza TLS (CA del sistema + CA adicional opcional). + */ +export const sapDispatcherProvider: Provider = { + provide: SAP_DISPATCHER, + inject: [AppConfigService], + useFactory: (config: AppConfigService) => { + const caFile = config.get('SAPB1_SL_CA_FILE'); + const ca = caFile + ? [...rootCertificates, readFileSync(resolve(caFile), 'utf8')] + : undefined; + return new Agent({ + connections: config.get('SAPB1_SL_MAX_CONCURRENCY'), + connect: { + ca, + rejectUnauthorized: config.get('SAPB1_SL_VERIFY_SSL'), + }, + }); + }, +}; diff --git a/apps/api/src/sap/sap-errors.ts b/apps/api/src/sap/sap-errors.ts new file mode 100644 index 0000000..119d14f --- /dev/null +++ b/apps/api/src/sap/sap-errors.ts @@ -0,0 +1,50 @@ +import { BadGatewayException, GatewayTimeoutException } from '@nestjs/common'; +import { SapHttpException } from './sap-http.exception.js'; + +const TLS_CODES = new Set([ + 'SELF_SIGNED_CERT_IN_CHAIN', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'CERT_HAS_EXPIRED', + 'ERR_TLS_CERT_ALTNAME_INVALID', +]); +const TIMEOUT_CODES = new Set([ + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_BODY_TIMEOUT', + 'ETIMEDOUT', +]); + +/** Traduce un fallo de red/TLS hacia SAP a una excepción HTTP entendible. */ +export function describeConnectionError(err: unknown) { + const code = (err as { code?: string }).code ?? ''; + if (TLS_CODES.has(code)) { + return new BadGatewayException( + `No se pudo validar el certificado del Service Layer (${code}). Configure SAPB1_SL_CA_FILE.`, + ); + } + if (TIMEOUT_CODES.has(code)) { + return new GatewayTimeoutException('Tiempo de espera agotado al conectar con SAP'); + } + return new BadGatewayException('No se pudo conectar con SAP Service Layer'); +} + +/** Convierte una respuesta de error del Service Layer en SapHttpException. */ +export function toSapException(status: number, text: string): SapHttpException { + let message = text.slice(0, 500) || `Error SAP (HTTP ${status})`; + let code: number | string | undefined; + try { + const err = (JSON.parse(text) as { + error?: { code?: number | string; message?: { value?: string } | string }; + }).error; + if (err) { + code = err.code; + message = typeof err.message === 'string' ? err.message : (err.message?.value ?? message); + } + } catch { + // cuerpo no JSON: se conserva el texto + } + // 401 desde SAP = problema del usuario de servicio, no de la sesión web. + return new SapHttpException(status === 401 ? 502 : status, message, code); +} diff --git a/apps/api/src/sap/sap-http.exception.ts b/apps/api/src/sap/sap-http.exception.ts new file mode 100644 index 0000000..517d90b --- /dev/null +++ b/apps/api/src/sap/sap-http.exception.ts @@ -0,0 +1,12 @@ +import { HttpException } from '@nestjs/common'; + +/** Error devuelto por el Service Layer, ya traducido para el cliente web. */ +export class SapHttpException extends HttpException { + constructor( + status: number, + message: string, + readonly sapCode?: number | string, + ) { + super({ statusCode: status, message, sapCode }, status); + } +} diff --git a/apps/api/src/sap/sap-session.service.ts b/apps/api/src/sap/sap-session.service.ts new file mode 100644 index 0000000..078c989 --- /dev/null +++ b/apps/api/src/sap/sap-session.service.ts @@ -0,0 +1,135 @@ +import { Inject, Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { BadGatewayException } from '@nestjs/common'; +import { type Dispatcher, request } from 'undici'; +import { AppConfigService } from '../config/app-config.service.js'; +import { SAP_DISPATCHER } from './sap.constants.js'; +import { describeConnectionError } from './sap-errors.js'; + +export interface SapSession { + /** Valor del header Cookie (B1SESSION + ROUTEID). */ + cookie: string; + sessionId: string; + createdAt: Date; +} + +/** + * Sesión compartida con el Service Layer (usuario de servicio con licencia). + * + * - Una sola sesión para todos los usuarios web. + * - `loginInFlight` actúa de mutex: si 20 peticiones necesitan sesión (o la + * sesión caducó y reciben 401 a la vez), se hace UN solo login y las demás + * esperan su resultado. + */ +@Injectable() +export class SapSessionService implements OnModuleDestroy { + private readonly logger = new Logger(SapSessionService.name); + private current?: SapSession; + private loginInFlight?: Promise; + + constructor( + private readonly config: AppConfigService, + @Inject(SAP_DISPATCHER) private readonly dispatcher: Dispatcher, + ) {} + + getSession(): Promise { + return this.current ? Promise.resolve(this.current) : this.login(); + } + + /** + * Renueva la sesión tras un 401. Si otra petición ya la renovó mientras + * esta esperaba, reutiliza la nueva en vez de hacer otro login. + */ + renew(stale: SapSession): Promise { + if (this.current && this.current !== stale) return Promise.resolve(this.current); + this.current = undefined; + return this.login(); + } + + private login(): Promise { + this.loginInFlight ??= this.doLogin().finally(() => { + this.loginInFlight = undefined; + }); + return this.loginInFlight; + } + + private async doLogin(): Promise { + const url = `${this.config.get('SAPB1_SL_URL')}/Login`; + const timeout = this.config.get('SAPB1_SL_TIMEOUT_MS'); + this.logger.log('Iniciando sesión en el Service Layer'); + + let res: Dispatcher.ResponseData; + try { + res = await request(url, { + method: 'POST', + dispatcher: this.dispatcher, + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ + CompanyDB: this.config.get('SAPB1_SL_COMPANYDB'), + UserName: this.config.get('SAPB1_SL_USER'), + Password: this.config.get('SAPB1_SL_PASSWORD'), + }), + headersTimeout: timeout, + bodyTimeout: timeout, + }); + } catch (err) { + this.logger.error(`No se pudo conectar para el login: ${(err as Error).message}`); + throw describeConnectionError(err); + } + + const text = await res.body.text(); + if (res.statusCode !== 200) { + // El login del usuario de servicio falló: es un problema de configuración + // del servidor, no de la sesión del usuario web -> 502, nunca 401 + // (el frontend interpreta 401 como "sesión expirada"). + this.logger.error(`Login SAP rechazado (HTTP ${res.statusCode})`); + throw new BadGatewayException('No se pudo iniciar sesión en SAP con el usuario de servicio'); + } + + const body = JSON.parse(text) as { SessionId?: string }; + const cookies = toArray(res.headers['set-cookie']) + .map((c) => c.split(';')[0].trim()) + .filter(Boolean); + if (!cookies.some((c) => c.startsWith('B1SESSION=')) && body.SessionId) { + cookies.unshift(`B1SESSION=${body.SessionId}`); + } + if (!body.SessionId || cookies.length === 0) { + throw new BadGatewayException('Respuesta de login SAP inesperada'); + } + + this.current = { cookie: cookies.join('; '), sessionId: body.SessionId, createdAt: new Date() }; + this.logger.log(`Sesión SAP establecida (${body.SessionId.slice(0, 8)}…)`); + return this.current; + } + + /** Estado sin exponer la cookie. */ + status() { + return this.current + ? { connected: true, sessionAgeSec: Math.round((Date.now() - this.current.createdAt.getTime()) / 1000) } + : { connected: false }; + } + + async onModuleDestroy() { + const session = this.current; + this.current = undefined; + if (session) { + try { + const res = await request(`${this.config.get('SAPB1_SL_URL')}/Logout`, { + method: 'POST', + dispatcher: this.dispatcher, + headers: { cookie: session.cookie }, + headersTimeout: 3000, + bodyTimeout: 3000, + }); + await res.body.dump(); + this.logger.log('Sesión SAP cerrada'); + } catch { + // al apagar no importa; la sesión caduca sola en el Service Layer + } + } + await this.dispatcher.close(); + } +} + +function toArray(v: string | string[] | undefined): string[] { + return v === undefined ? [] : Array.isArray(v) ? v : [v]; +} diff --git a/apps/api/src/sap/sap.constants.ts b/apps/api/src/sap/sap.constants.ts new file mode 100644 index 0000000..096c771 --- /dev/null +++ b/apps/api/src/sap/sap.constants.ts @@ -0,0 +1,2 @@ +/** Token del dispatcher HTTP (undici) usado para hablar con el Service Layer. */ +export const SAP_DISPATCHER = Symbol('SAP_DISPATCHER'); diff --git a/apps/api/src/sap/sap.module.ts b/apps/api/src/sap/sap.module.ts new file mode 100644 index 0000000..b3ecee0 --- /dev/null +++ b/apps/api/src/sap/sap.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { sapDispatcherProvider } from './sap-dispatcher.provider.js'; +import { SapClientService } from './sap-client.service.js'; +import { SapSessionService } from './sap-session.service.js'; + +@Module({ + providers: [sapDispatcherProvider, SapSessionService, SapClientService], + exports: [SapClientService, SapSessionService], +}) +export class SapModule {} diff --git a/apps/api/test/health.e2e-spec.ts b/apps/api/test/health.e2e-spec.ts index 0624ce2..eaac46b 100644 --- a/apps/api/test/health.e2e-spec.ts +++ b/apps/api/test/health.e2e-spec.ts @@ -1,12 +1,18 @@ import { Test } from '@nestjs/testing'; import type { INestApplication } from '@nestjs/common'; import request from 'supertest'; -import { AppModule } from '../src/app.module.js'; + +// Valores ficticios: este e2e no se conecta a SAP (el login es perezoso). +process.env.SAPB1_SL_URL ??= 'https://sap.invalid:50000/b1s/v1'; +process.env.SAPB1_SL_COMPANYDB ??= 'DB'; +process.env.SAPB1_SL_USER ??= 'u'; +process.env.SAPB1_SL_PASSWORD ??= 'p'; describe('Health (e2e)', () => { let app: INestApplication; beforeAll(async () => { + const { AppModule } = await import('../src/app.module.js'); const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = moduleRef.createNestApplication(); app.setGlobalPrefix('api'); diff --git a/apps/api/test/sap-live.e2e-spec.ts b/apps/api/test/sap-live.e2e-spec.ts new file mode 100644 index 0000000..059021d --- /dev/null +++ b/apps/api/test/sap-live.e2e-spec.ts @@ -0,0 +1,29 @@ +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'; + +// Prueba contra un Service Layer real (usa apps/api/.env). Solo con SAP_LIVE=1: +// SAP_LIVE=1 npm run test:e2e +describe.skipIf(!process.env.SAP_LIVE)('SAP Service Layer (live)', () => { + let app: INestApplication; + let sap: SapClientService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = moduleRef.createNestApplication(); + await app.init(); + sap = app.get(SapClientService); + }, 30000); + + afterAll(async () => { + await app.close(); + }); + + it('10 peticiones concurrentes se encolan y no agotan el timeout', async () => { + const res = await Promise.all( + Array.from({ length: 10 }, () => sap.get<{ value: unknown[] }>('Items', { $top: 1, $select: 'ItemCode' })), + ); + expect(res.every((r) => r.status === 200 && Array.isArray(r.data.value))).toBe(true); + }, 30000); +}); diff --git a/package-lock.json b/package-lock.json index 3e66122..2e603ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "libs/*" ], "engines": { - "node": ">=22" + "node": ">=22", + "npm": ">=11" } }, "apps/api": { @@ -25,6 +26,7 @@ "@nestjs/platform-express": "^12.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "undici": "^6.20.1", "zod": "^4.6.7" }, "devDependencies": { @@ -5510,7 +5512,6 @@ "version": "6.20.1", "resolved": "https://registry.npmjs.org/undici/-/undici-6.20.1.tgz", "integrity": "sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA==", - "dev": true, "license": "MIT", "engines": { "node": ">=18.17"