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

+56 -15
View File
@@ -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>;