feat(api): capa SAP con sesión compartida, mutex de login y cola de peticiones

- 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 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 18:29:13 -05:00
1 parent 445efc998d
commit bb4acb5510
19 files changed
+616 -9

No files matched your search

+5
View File
@@ -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
+22
View File
@@ -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
+23
View File
@@ -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/<nombre>` puede quitar esa regla de `.gitignore` y versionar los suyos.
+1
View File
@@ -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": {
+2 -1
View File
@@ -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 {}
+36 -3
View File
@@ -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);
});
});
+43 -2
View File
@@ -1,6 +1,11 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { z } from 'zod';
export const envSchema = z.object({
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.
@@ -14,7 +19,43 @@ export const envSchema = z.object({
.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<typeof envSchema>;
+14
View File
@@ -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() };
}
}
@@ -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<string, unknown> = {
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<MockAgent['get']>;
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('');
});
});
+94
View File
@@ -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<string, string | number | boolean | undefined>;
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<string, string>;
}
export interface SapResponse<T = unknown> {
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<T = unknown>(req: SapRequest): Promise<SapResponse<T>> {
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<T = unknown>(path: string, query?: SapQuery, headers?: Record<string, string>) {
return this.request<T>({ 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('&')}` : '';
}
@@ -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'),
},
});
},
};
+50
View File
@@ -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);
}
+12
View File
@@ -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);
}
}
+135
View File
@@ -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<SapSession>;
constructor(
private readonly config: AppConfigService,
@Inject(SAP_DISPATCHER) private readonly dispatcher: Dispatcher,
) {}
getSession(): Promise<SapSession> {
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<SapSession> {
if (this.current && this.current !== stale) return Promise.resolve(this.current);
this.current = undefined;
return this.login();
}
private login(): Promise<SapSession> {
this.loginInFlight ??= this.doLogin().finally(() => {
this.loginInFlight = undefined;
});
return this.loginInFlight;
}
private async doLogin(): Promise<SapSession> {
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];
}
+2
View File
@@ -0,0 +1,2 @@
/** Token del dispatcher HTTP (undici) usado para hablar con el Service Layer. */
export const SAP_DISPATCHER = Symbol('SAP_DISPATCHER');
+10
View File
@@ -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 {}
+7 -1
View File
@@ -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');
+29
View File
@@ -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);
});
+3 -2
View File
@@ -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"