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:
1 parent
445efc998d
commit
bb4acb5510
19 files changed
+629
-22
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof envSchema>;
|
||||
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Token del dispatcher HTTP (undici) usado para hablar con el Service Layer. */
|
||||
export const SAP_DISPATCHER = Symbol('SAP_DISPATCHER');
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Generated
+3
-2
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user