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
+629 -22

No files matched your search

@@ -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 {}