Initial commit: sap-webhook-api
API NestJS que recibe el Webhook Messenger de SAP Business One (CloudEvents), valida HMAC opcional y guarda cada notificacion local (data/events/) e inserta en Postgres (webhook_events). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
5e0df73477
28 files changed
+11773
No files matched your search
@@ -0,0 +1,17 @@
|
||||
# Puerto donde escucha la API (default 3000)
|
||||
PORT=3000
|
||||
|
||||
# Postgres compartida con sap-intercompany-worker (ver docker-compose.yml en la raíz)
|
||||
DATABASE_URL=postgresql://intercompany:intercompany@localhost:5432/sap_intercompany?schema=public
|
||||
|
||||
# Si se define, la API exige la firma X-B1-Webhook-Signature (HMAC-SHA256)
|
||||
# en /webhooks/sap. Si se deja vacío/comentado, acepta todo sin firma
|
||||
# (equivalente a AuthenticationType: None en la suscripción de SAP).
|
||||
HMAC_SECRET_KEY=
|
||||
|
||||
# Datos usados solo por los scripts en scripts/ (Login + PATCH a Service Layer)
|
||||
SERVICE_LAYER_URL=https://127.0.0.1:50000/b1s/v2
|
||||
COMPANY_DB=SBODemoCL
|
||||
SL_USER=manager
|
||||
SL_PASS=manager
|
||||
WEBHOOK_ID=SapWebhookApiTest
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
certs/*.pem
|
||||
data/events/*.json
|
||||
.DS_Store
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# sap-webhook-api
|
||||
|
||||
API en NestJS que escucha el Webhook Messenger de SAP Business One
|
||||
(Service Layer) y captura cada Event Notification que llega.
|
||||
|
||||
## Qué hace
|
||||
|
||||
- Expone `POST /webhooks/sap` por **HTTPS** (certificado autofirmado en
|
||||
`certs/`), pensado para responder rápido y no arriesgar el timeout de
|
||||
10s que impone el Webhook Messenger de SAP.
|
||||
- Soporta el payload real de SAP: un array de eventos en formato
|
||||
[CloudEvents](https://cloudevents.io/).
|
||||
- Guarda cada notificación como:
|
||||
- un archivo `.json` en `data/events/` (uno por evento, nombrado por su
|
||||
`id`), y
|
||||
- una fila en Postgres (tabla `webhook_events`), para que
|
||||
[`sap-intercompany-worker`](../sap-intercompany-worker) la pueda
|
||||
procesar de forma asíncrona.
|
||||
- Soporta autenticación `None` (default) o `HMAC` (`X-B1-Webhook-Signature`,
|
||||
HMAC-SHA256), activable/desactivable con un script que sincroniza SAP y
|
||||
la propia API — ver `docs/EJECUCION.md`.
|
||||
- Expone también `GET /webhooks/sap` (chequeo de vida / handshake) porque
|
||||
SAP lo usa para validar el endpoint antes de despachar eventos.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # completar con los datos reales de tu Service Layer
|
||||
|
||||
# Postgres compartida con sap-intercompany-worker (una sola vez, desde la raíz del repo)
|
||||
cd .. && docker compose up -d && cd sap-webhook-api
|
||||
|
||||
npm run start
|
||||
```
|
||||
|
||||
La API queda escuchando en `https://<tu-ip>:3000/webhooks/sap`.
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
src/
|
||||
main.ts # bootstrap, HTTPS
|
||||
webhooks/
|
||||
webhooks.controller.ts # GET/POST /webhooks/sap
|
||||
webhook-events.repository.ts # insert a Postgres (webhook_events)
|
||||
certs/ # certificado autofirmado (key.pem / cert.pem)
|
||||
data/events/ # cada notificación, como .json
|
||||
scripts/ # enable/disable-hmac-auth.sh
|
||||
tools/sl.http # requests de referencia contra el Service Layer
|
||||
docs/ # bitácora, arquitectura, guía de ejecución
|
||||
```
|
||||
|
||||
## Documentación
|
||||
|
||||
- `docs/PASOS-REALIZADOS.md` — bitácora paso a paso de cómo se armó el
|
||||
proyecto y se configuró la suscripción en SAP.
|
||||
- `docs/EJECUCION.md` — cómo correr la API en modo `None` y en modo `HMAC`.
|
||||
- `docs/ARQUITECTURA.md` / `docs/ARQUITECTURA.pdf` — diagrama de
|
||||
arquitectura (servidores, puertos, certificados).
|
||||
- `tools/sl.http` — requests de ejemplo (Login, EventSubscriptions, HMAC)
|
||||
para usar con la extensión REST Client / httpyac.
|
||||
|
||||
Para el diagnóstico del bug de SAP que bloqueaba la entrega de eventos
|
||||
(`DATEFORMAT`), y el resto del contexto de la sesión, ver `CLAUDE.md` en la
|
||||
raíz del repo.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Del alta de una Orden al archivo JSON
|
||||
|
||||
Cómo viaja un evento de SAP Business One hasta quedar guardado en disco por
|
||||
`sap-webhook-api`: los dos servidores involucrados, sus componentes,
|
||||
puertos e IPs de ejemplo, y dónde entra en juego el certificado
|
||||
autofirmado.
|
||||
|
||||
## Diagrama
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph SAP["SAP Business One — servidor Windows · 192.168.1.205"]
|
||||
direction TB
|
||||
SL["Service Layer\nsap10:50000\nLogin · EventSubscriptions\ncert propio autofirmado"]
|
||||
SQL["SQL Server (MSSQL)\nDB: SBODemoCL\nTablas: OEVC · OEVN · EVN1\nlogin: sa"]
|
||||
WM["Webhook Messenger\npuerto 40008\nWebhookMessenger.jar\nDEFAULT_LANGUAGE=us_english (workaround)"]
|
||||
SL <-->|"Login / EventSubscriptions"| SQL
|
||||
SQL <-->|"SELECT/UPDATE OEVN, EVN1 (login sa)"| WM
|
||||
end
|
||||
|
||||
subgraph MAC["Mac de desarrollo · 192.168.1.166"]
|
||||
direction TB
|
||||
CFG["Configuración\ncurl / sl.http"]
|
||||
API["sap-webhook-api (NestJS)\n192.168.1.166:3000\nPOST /webhooks/sap\ncert CN/SAN=192.168.1.166"]
|
||||
FS["data/events/*.json\n1 archivo por notificación"]
|
||||
API -->|guarda| FS
|
||||
end
|
||||
|
||||
CFG -- "HTTPS :50000\nLogin, EventSubscriptions" --> SL
|
||||
WM -- "HTTPS :3000\nCloudEvents batch\nPOST /webhooks/sap" --> API
|
||||
|
||||
classDef warn fill:#faf0dd,stroke:#a8690b,color:#7a4c08;
|
||||
class WM warn
|
||||
```
|
||||
|
||||
Dos servidores en la misma LAN. La Mac llama al Service Layer para
|
||||
configurar la suscripción (flecha superior); el Webhook Messenger — que
|
||||
corre dentro del servidor SAP y lee la cola de eventos en SQL Server
|
||||
autenticado como `sa` — es quien llama de vuelta a la Mac para entregar
|
||||
cada evento (flecha inferior). El recuadro en ámbar marca el workaround de
|
||||
`DATEFORMAT` aplicado para que esa segunda flecha funcione.
|
||||
|
||||
## Componentes e IPs de ejemplo
|
||||
|
||||
| Componente | Host : puerto | Protocolo | Notas |
|
||||
|---|---|---|---|
|
||||
| **Service Layer** (SAP Business One) | `sap10 : 50000` | HTTPS | Certificado propio autofirmado; nuestras llamadas lo ignoran con `curl -k`. |
|
||||
| **Webhook Messenger** (servicio Windows) | `192.168.1.205 : 40008` | TCP (health) + JDBC a SQL Server | Se conecta a SQL Server como `sa`; despacha los eventos por HTTPS hacia el `WebhookURL` suscripto. |
|
||||
| **SQL Server** (MSSQL) | — (interno) | TDS | DB `SBODemoCL`. Tablas `OEVC` / `OEVN` / `EVN1`. |
|
||||
| **sap-webhook-api** (NestJS) | `192.168.1.166 : 3000` | HTTPS | Certificado autofirmado propio, `CN/SAN = 192.168.1.166`. |
|
||||
| **data/events/** (filesystem local) | — (disco) | — | Un archivo `.json` por notificación recibida. |
|
||||
|
||||
## 🔐 Certificado autofirmado
|
||||
|
||||
Generado en la Mac para `CN/SAN = 192.168.1.166` — la IP donde corre
|
||||
`sap-webhook-api`, no la del servidor SAP.
|
||||
|
||||
Ese nombre **tiene que coincidir** con el host que se declara como
|
||||
`WebhookURL` al crear la suscripción
|
||||
(`https://192.168.1.166:3000/webhooks/sap`). Como la suscripción usa
|
||||
`VerifyCertificate: tNO`, SAP nunca valida este certificado — si más
|
||||
adelante se activa la verificación, hay que importarlo además en el
|
||||
keystore del propio Webhook Messenger.
|
||||
|
||||
## 🟠 Estado del bug de fechas
|
||||
|
||||
El Webhook Messenger construye el SQL contra `OEVN` con la fecha como
|
||||
texto plano. Con el login `sa` en idioma `Español` (`DATEFORMAT dmy`),
|
||||
cualquier día del mes mayor a 12 rompía la consulta.
|
||||
|
||||
Workaround confirmado en este ambiente de desarrollo: cambiar `sa` a
|
||||
`DEFAULT_LANGUAGE = us_english` y reiniciar el servicio. Sigue pendiente
|
||||
confirmar si `FP2605 HFX1` lo corrige oficialmente.
|
||||
|
||||
---
|
||||
|
||||
Detalle completo del diagnóstico, la evidencia y los pasos de instalación
|
||||
en `CLAUDE.md` y `PASOS-REALIZADOS.md` de este repositorio.
|
||||
Binary file not shown.
@@ -0,0 +1,114 @@
|
||||
# Cómo ejecutar la API (con y sin HMAC)
|
||||
|
||||
Guía rápida para levantar `sap-webhook-api` en los dos modos soportados:
|
||||
sin autenticación (`AuthenticationType: None`, el modo actual de la
|
||||
suscripción real en SAP) y con firma HMAC.
|
||||
|
||||
## 0. Una sola vez: preparar el entorno
|
||||
|
||||
```bash
|
||||
cd sap-webhook-api
|
||||
npm install
|
||||
cp .env.example .env # si todavía no existe .env
|
||||
```
|
||||
|
||||
Revisá que `.env` tenga los datos correctos de tu Service Layer
|
||||
(`SERVICE_LAYER_URL`, `COMPANY_DB`, `SL_USER`, `SL_PASS`, `WEBHOOK_ID`). El
|
||||
certificado HTTPS (`certs/key.pem` / `certs/cert.pem`) ya debería existir —
|
||||
si no, ver `PASOS-REALIZADOS.md`, sección 9.
|
||||
|
||||
**Importante**: el modo con el que arranca la API (`.env` →
|
||||
`HMAC_SECRET_KEY`) tiene que coincidir con el `AuthenticationType`
|
||||
configurado en la suscripción real de SAP. Si no coinciden, SAP manda
|
||||
notificaciones que la API rechaza (o al revés, la API acepta sin validar
|
||||
nada aunque SAP crea que está firmando). Los scripts de la sección 2 se
|
||||
encargan de mantenerlos sincronizados.
|
||||
|
||||
## 1. Modo sin autenticación (`AuthenticationType: None`)
|
||||
|
||||
Es el modo por defecto — no requiere ningún paso extra.
|
||||
|
||||
```bash
|
||||
# confirmá que HMAC_SECRET_KEY esté vacío en .env
|
||||
npm run start
|
||||
```
|
||||
|
||||
La API queda escuchando en `https://<tu-ip>:3000/webhooks/sap`, acepta
|
||||
cualquier notificación sin validar firma. Prueba local:
|
||||
|
||||
```bash
|
||||
curl -sk -i -X POST https://localhost:3000/webhooks/sap \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[{"id":"test-1","type":"sap.b1.Orders.Created.v1","subject":"1","data":{"DocEntry":"1"}}]'
|
||||
# -> 200 OK
|
||||
```
|
||||
|
||||
## 2. Modo con HMAC (`AuthenticationType: HMAC`)
|
||||
|
||||
### 2.1 Activar
|
||||
|
||||
```bash
|
||||
npm run auth:hmac:on
|
||||
```
|
||||
|
||||
Esto:
|
||||
1. Genera un `HMAC_SECRET_KEY` (si `.env` no tenía uno) y lo guarda en `.env`.
|
||||
2. Hace `Login` en el Service Layer, lee la configuración actual de la
|
||||
suscripción (`WEBHOOK_ID` de `.env`), la **pausa**, la **elimina** y la
|
||||
**vuelve a crear** con `AuthenticationType: "HMAC"` — el Service Layer
|
||||
no permite cambiar `AuthenticationType` con `PATCH` sobre una
|
||||
suscripción existente, así que este es el único camino soportado
|
||||
(se conserva `WebhookURL`, `Handshake` y `EventCollection` originales).
|
||||
`AuthenticationCred` no es el secreto pelado: va como un string con
|
||||
JSON embebido, `{"Key": "<secreto>", "Algorithm": "hmacsha256"}`.
|
||||
|
||||
Después, **reiniciá la API** para que tome el `HMAC_SECRET_KEY` nuevo:
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
A partir de este momento, todo `POST /webhooks/sap` sin la firma correcta
|
||||
en el header `X-B1-Webhook-Signature` (HMAC-SHA256 del body, en base64)
|
||||
devuelve `401 Unauthorized`.
|
||||
|
||||
### 2.2 Probar manualmente (simulando lo que hace SAP)
|
||||
|
||||
```bash
|
||||
SECRET=$(grep '^HMAC_SECRET_KEY=' .env | cut -d= -f2)
|
||||
BODY='[{"id":"test-2","type":"sap.b1.Orders.Created.v1","subject":"2","data":{"DocEntry":"2"}}]'
|
||||
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64)
|
||||
|
||||
curl -sk -i -X POST https://localhost:3000/webhooks/sap \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-B1-Webhook-Signature: $SIG" \
|
||||
-d "$BODY"
|
||||
# -> 200 OK
|
||||
|
||||
# sin firma o con una firma incorrecta -> 401
|
||||
curl -sk -i -X POST https://localhost:3000/webhooks/sap \
|
||||
-H "Content-Type: application/json" -d "$BODY"
|
||||
```
|
||||
|
||||
### 2.3 Desactivar (volver a `None`)
|
||||
|
||||
```bash
|
||||
npm run auth:hmac:off
|
||||
npm run start # reiniciar para que la API deje de exigir la firma
|
||||
```
|
||||
|
||||
Esto revierte la suscripción en SAP a `AuthenticationType: "None"` y vacía
|
||||
`HMAC_SECRET_KEY` en `.env`, en un solo paso.
|
||||
|
||||
## Referencia rápida
|
||||
|
||||
| Acción | Comando |
|
||||
|---|---|
|
||||
| Instalar dependencias | `npm install` |
|
||||
| Levantar la API | `npm run start` |
|
||||
| Activar HMAC (API + SAP) | `npm run auth:hmac:on` |
|
||||
| Desactivar HMAC (API + SAP) | `npm run auth:hmac:off` |
|
||||
| Ver estado actual de la suscripción | `curl -sk -b cookies.txt "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')"` (después de un `Login`) |
|
||||
|
||||
Detalle de la implementación (cómo se valida la firma, por qué se usa el
|
||||
body crudo, etc.) en `CLAUDE.md`.
|
||||
@@ -0,0 +1,291 @@
|
||||
# Pasos realizados — API de Webhooks SAP Business One
|
||||
|
||||
Documento de los pasos ejecutados para levantar la API NestJS que escucha el
|
||||
Event Notification del Webhook de SAP Business One Service Layer, desde el
|
||||
proyecto vacío hasta tener la suscripción activa y lista para recibir
|
||||
notificaciones reales. No incluye las pruebas contra SAP real (creación de
|
||||
órdenes de venta) ni la investigación de errores posteriores — eso queda
|
||||
documentado por separado.
|
||||
|
||||
## Contexto / objetivo
|
||||
|
||||
- API sencilla en NestJS que escucha el Webhook Messenger de SAP Business One
|
||||
(Service Layer) y guarda localmente cada Event Notification recibido.
|
||||
- SAP Business One corre en `sap10` (Service Layer, puerto `50000`) y en
|
||||
`192.168.1.205` (Webhook Messenger, puerto `40008`), en la misma LAN que la
|
||||
máquina de desarrollo (Mac, IP `192.168.1.166`).
|
||||
- Compañía de pruebas: `SBODemoCL`, usuario `manager`.
|
||||
|
||||
## 1. Crear el proyecto NestJS
|
||||
|
||||
```bash
|
||||
npx --yes @nestjs/cli new sap-webhook-api --package-manager npm --skip-git
|
||||
```
|
||||
|
||||
Genera el scaffold estándar de Nest (TypeScript, npm) en `sap-webhook-api/`.
|
||||
|
||||
## 2. Crear el módulo de webhooks
|
||||
|
||||
```bash
|
||||
cd sap-webhook-api
|
||||
npx nest generate module webhooks --no-spec
|
||||
npx nest generate controller webhooks --no-spec
|
||||
```
|
||||
|
||||
Esto registra automáticamente `WebhooksModule` en `AppModule` y crea
|
||||
`src/webhooks/webhooks.controller.ts`.
|
||||
|
||||
## 3. Endpoint mínimo que recibe el webhook
|
||||
|
||||
`src/webhooks/webhooks.controller.ts`:
|
||||
|
||||
```ts
|
||||
import { Body, Controller, HttpCode, Logger, Post } from '@nestjs/common';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const EVENTS_DIR = join(process.cwd(), 'data', 'events');
|
||||
|
||||
interface SapEventNotification {
|
||||
EventID?: string;
|
||||
SourceDB?: string;
|
||||
BusinessObject?: string;
|
||||
Operation?: string;
|
||||
Status?: string;
|
||||
ReplayState?: string;
|
||||
FieldNames?: string;
|
||||
FieldValues?: string;
|
||||
CreateDate?: string;
|
||||
CreateTime?: string;
|
||||
}
|
||||
|
||||
@Controller('webhooks')
|
||||
export class WebhooksController {
|
||||
private readonly logger = new Logger(WebhooksController.name);
|
||||
|
||||
@Post('sap')
|
||||
@HttpCode(200)
|
||||
receiveSapEvent(@Body() body: SapEventNotification | SapEventNotification[]) {
|
||||
const notifications = Array.isArray(body) ? body : [body];
|
||||
this.logger.log(`Recibidas ${notifications.length} notificación(es) de SAP`);
|
||||
|
||||
mkdirSync(EVENTS_DIR, { recursive: true });
|
||||
notifications.forEach((notification, index) => {
|
||||
const id = notification?.EventID ?? `${Date.now()}-${index}`;
|
||||
const fileName = `${id}.json`;
|
||||
writeFileSync(join(EVENTS_DIR, fileName), JSON.stringify(notification, null, 2));
|
||||
this.logger.log(`Evento guardado: ${fileName}`);
|
||||
});
|
||||
|
||||
return { received: true, count: notifications.length };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
SAP puede enviar varias notificaciones "bateadas" en un solo `POST` (según
|
||||
`MessageBatchLimit`), como confirma el sample oficial de SAP
|
||||
(`webhook-service-sample-nodejs`). Por eso el body se normaliza siempre a un
|
||||
array, y se guarda **un archivo por notificación**, nombrado por su
|
||||
`EventID` cuando está disponible (o timestamp + índice como respaldo).
|
||||
|
||||
> Nota: esta versión reemplaza a una anterior más simple que guardaba el
|
||||
> `body` completo en un solo archivo por request — ver historial de cambios
|
||||
> del proyecto si se necesita esa versión.
|
||||
|
||||
## 4. Primera prueba local (HTTP)
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
curl -s -i -X POST http://localhost:3000/webhooks/sap \
|
||||
-H "Content-Type: application/json" -d '{"test":"hola"}'
|
||||
```
|
||||
|
||||
Resultado: `200 OK`, `{"received":true}`, y log
|
||||
`Evento recibido de SAP: {"test":"hola"}`.
|
||||
|
||||
## 5. Verificar conectividad de red en la LAN
|
||||
|
||||
- IP de la Mac en la LAN: `192.168.1.166` (`ipconfig getifaddr en0`).
|
||||
- Confirmar que Node escucha en todas las interfaces, no solo localhost:
|
||||
|
||||
```bash
|
||||
lsof -nP -iTCP:3000 -sTCP:LISTEN
|
||||
# node ... TCP *:3000 (LISTEN)
|
||||
```
|
||||
|
||||
- Desde el servidor SAP (Windows), verificar que puede llegar a la Mac en el
|
||||
puerto `3000` (macOS no trae `telnet`, así que se usó `Test-NetConnection`):
|
||||
|
||||
```powershell
|
||||
Test-NetConnection -ComputerName 192.168.1.166 -Port 3000
|
||||
# TcpTestSucceeded : True
|
||||
```
|
||||
|
||||
- Desde la Mac, verificar conectividad hacia el Webhook Messenger de SAP
|
||||
(puerto `40008`) y hacia el Service Layer (puerto `50000`), con `nc`
|
||||
(netcat, viene instalado en macOS):
|
||||
|
||||
```bash
|
||||
nc -zv 192.168.1.205 40008 # Connection succeeded
|
||||
nc -zv sap10 50000 # Connection succeeded
|
||||
```
|
||||
|
||||
Con esto queda confirmada la conectividad en ambos sentidos.
|
||||
|
||||
## 6. Login en el Service Layer y estado del webhook
|
||||
|
||||
```bash
|
||||
curl -sk -i -c cookies.txt -X POST https://sap10:50000/b1s/v2/Login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"CompanyDB":"SBODemoCL","UserName":"manager","Password":"mngr"}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -sk -b cookies.txt https://sap10:50000/b1s/v2/CompanyService_GetAdminInfo
|
||||
```
|
||||
|
||||
El campo `EnableWebhook` ya estaba en `"tYES"` en esta compañía, así que no
|
||||
hizo falta llamar a `CompanyService_UpdateAdminInfo`.
|
||||
|
||||
## 7. Confirmar el evento en el catálogo
|
||||
|
||||
```bash
|
||||
curl -sk -b cookies.txt \
|
||||
"https://sap10:50000/b1s/v2/EventSubscriptionsService_GetEventCatalog"
|
||||
```
|
||||
|
||||
Se confirmó la entrada:
|
||||
|
||||
```json
|
||||
{"BusinessObject": "Orders", "ObjectType": "17", "TransactionTypes": "Created,Updated,Deleted,Closed,Cancelled,Reopened"}
|
||||
```
|
||||
|
||||
## 8. Primer intento de suscripción (falló — HTTP no permitido)
|
||||
|
||||
```bash
|
||||
curl -sk -i -b cookies.txt -X POST https://sap10:50000/b1s/v2/EventSubscriptions \
|
||||
-H "Content-Type: application/json" -d '{
|
||||
"WebhookID": "SapWebhookApiTest",
|
||||
"WebhookURL": "http://192.168.1.166:3000/webhooks/sap",
|
||||
"AuthenticationType": "None",
|
||||
"Handshake": "tNO",
|
||||
"EventCollection": [{"BusinessObject": "Orders", "TransactionType": "Created"}]
|
||||
}'
|
||||
```
|
||||
|
||||
Resultado: `400 Bad Request`, `-21100 Invalid webhook URL` — SAP exige que
|
||||
`WebhookURL` sea HTTPS.
|
||||
|
||||
## 9. Certificado autofirmado y API en HTTPS
|
||||
|
||||
```bash
|
||||
cd sap-webhook-api
|
||||
mkdir -p certs
|
||||
openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem \
|
||||
-days 825 -nodes -subj "/CN=192.168.1.166" \
|
||||
-addext "subjectAltName=IP:192.168.1.166"
|
||||
```
|
||||
|
||||
`src/main.ts`:
|
||||
|
||||
```ts
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const httpsOptions = {
|
||||
key: readFileSync(join(__dirname, '..', 'certs', 'key.pem')),
|
||||
cert: readFileSync(join(__dirname, '..', 'certs', 'cert.pem')),
|
||||
};
|
||||
const app = await NestFactory.create(AppModule, { httpsOptions });
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
```
|
||||
|
||||
Prueba local tras el cambio:
|
||||
|
||||
```bash
|
||||
curl -sk -i -X POST https://localhost:3000/webhooks/sap \
|
||||
-H "Content-Type: application/json" -d '{"test":"https-ok"}'
|
||||
ls -la data/events/
|
||||
```
|
||||
|
||||
Resultado: `200 OK` y el archivo del evento guardado correctamente en
|
||||
`data/events/`.
|
||||
|
||||
## 10. Crear la suscripción con HTTPS (éxito)
|
||||
|
||||
```bash
|
||||
curl -sk -i -b cookies.txt -X POST https://sap10:50000/b1s/v2/EventSubscriptions \
|
||||
-H "Content-Type: application/json" -d '{
|
||||
"WebhookID": "SapWebhookApiTest",
|
||||
"WebhookURL": "https://192.168.1.166:3000/webhooks/sap",
|
||||
"AuthenticationType": "None",
|
||||
"Handshake": "tNO",
|
||||
"EventCollection": [{"BusinessObject": "Orders", "TransactionType": "Created"}]
|
||||
}'
|
||||
```
|
||||
|
||||
Resultado: `201 Created`, `State: "Active"` — pero con `VerifyCertificate:
|
||||
"tYES"` (SAP intentaría validar nuestro certificado autofirmado y lo
|
||||
rechazaría).
|
||||
|
||||
## 11. Desactivar la verificación de certificado
|
||||
|
||||
```bash
|
||||
curl -sk -i -b cookies.txt -X PATCH \
|
||||
"https://sap10:50000/b1s/v2/EventSubscriptions('SapWebhookApiTest')" \
|
||||
-H "Content-Type: application/json" -d '{"VerifyCertificate": "tNO"}'
|
||||
```
|
||||
|
||||
Resultado: `204 No Content`. Verificado con un `GET` posterior:
|
||||
`VerifyCertificate: "tNO"`, `State: "Active"`.
|
||||
|
||||
## Estado al cierre de esta etapa
|
||||
|
||||
| Ítem | Estado |
|
||||
|---|---|
|
||||
| Proyecto NestJS (`sap-webhook-api`) | ✅ Creado |
|
||||
| Endpoint `POST /webhooks/sap` | ✅ Responde 200 y guarda cada evento en `data/events/` |
|
||||
| Conectividad LAN (Mac ↔ SAP, ambos sentidos) | ✅ Confirmada |
|
||||
| Webhook habilitado en la compañía SAP | ✅ (ya estaba `tYES`) |
|
||||
| Certificado HTTPS autofirmado | ✅ Generado y en uso |
|
||||
| Suscripción `SapWebhookApiTest` (`Orders`/`Created`) | ✅ `Active`, `VerifyCertificate: tNO` |
|
||||
|
||||
A partir de aquí comienzan las pruebas con órdenes de venta reales en SAP, que
|
||||
llevaron a identificar un bug en el Webhook Messenger de SAP (no cubierto en
|
||||
este documento).
|
||||
|
||||
## Anexo: tablas internas de SAP para diagnóstico
|
||||
|
||||
Para diagnosticar problemas de entrega del webhook (suscripción activa pero
|
||||
sin eventos llegando, errores de envío, etc.), conviene revisar directamente
|
||||
estas tablas en la base de la compañía (ej. `SBODemoCL`), vía SSMS, en modo
|
||||
**solo lectura**:
|
||||
|
||||
```sql
|
||||
-- Suscripciones de webhook registradas (una fila por WebhookID: URL, estado,
|
||||
-- fecha/hora de creación, último envío, último error)
|
||||
SELECT * FROM OEVC
|
||||
|
||||
-- Cola de eventos de negocio generados (una fila por transacción que matchea
|
||||
-- algún BusinessObject/TransactionType suscripto: EventID, CreateDate,
|
||||
-- CreateTime, Status, BizObject, TransType). Status = 'N' significa
|
||||
-- pendiente de enviar.
|
||||
SELECT * FROM OEVN
|
||||
|
||||
-- Estado de envío de cada evento hacia cada webhook suscripto (relaciona
|
||||
-- EventID + WebhookID con su resultado: Status, Retry, ReturnCode,
|
||||
-- LastSentDT, LastSentTM)
|
||||
SELECT * FROM EVN1
|
||||
```
|
||||
|
||||
Estas tablas son internas del motor de SAP Business One — **no están
|
||||
pensadas para escritura directa vía SQL** (cualquier cambio debería hacerse
|
||||
a través de Service Layer / DI API). Sirven únicamente para inspeccionar el
|
||||
estado real de la cola de eventos y de las suscripciones cuando el
|
||||
comportamiento reportado por el Service Layer (`GET /EventSubscriptions`) no
|
||||
alcanza para explicar por qué no llegan las notificaciones.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 553 KiB |
@@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+10354
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "sap-webhook-api",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"auth:hmac:on": "bash scripts/enable-hmac-auth.sh",
|
||||
"auth:hmac:off": "bash scripts/disable-hmac-auth.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"pg": "^8.23.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Revierte la suscripción de SAP a AuthenticationType: None, y vacía
|
||||
# HMAC_SECRET_KEY en .env para que la API deje de exigir la firma.
|
||||
#
|
||||
# El Service Layer no permite cambiar AuthenticationType con PATCH sobre
|
||||
# una suscripción existente -> hay que Pause + Delete + volver a crearla
|
||||
# (POST), reusando su WebhookURL/Handshake/EventCollection actuales.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
ENV_FILE=".env"
|
||||
[ -f "$ENV_FILE" ] || { echo "No existe $ENV_FILE (copiá .env.example primero)"; exit 1; }
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
: "${SERVICE_LAYER_URL:?Falta SERVICE_LAYER_URL en .env}"
|
||||
: "${COMPANY_DB:?Falta COMPANY_DB en .env}"
|
||||
: "${SL_USER:?Falta SL_USER en .env}"
|
||||
: "${SL_PASS:?Falta SL_PASS en .env}"
|
||||
: "${WEBHOOK_ID:?Falta WEBHOOK_ID en .env}"
|
||||
|
||||
COOKIE_JAR="$(mktemp)"
|
||||
trap 'rm -f "$COOKIE_JAR"' EXIT
|
||||
|
||||
echo "Login en Service Layer ($SERVICE_LAYER_URL, compañía $COMPANY_DB)..."
|
||||
curl -sk -c "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||
-X POST "$SERVICE_LAYER_URL/Login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"CompanyDB\":\"$COMPANY_DB\",\"UserName\":\"$SL_USER\",\"Password\":\"$SL_PASS\"}"
|
||||
|
||||
echo "Leyendo configuración actual de '$WEBHOOK_ID'..."
|
||||
CURRENT=$(curl -sk -b "$COOKIE_JAR" "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')")
|
||||
|
||||
NEW_BODY=$(echo "$CURRENT" | python3 -c "
|
||||
import json, sys
|
||||
current = json.load(sys.stdin)
|
||||
body = {
|
||||
'WebhookID': current['WebhookID'],
|
||||
'WebhookURL': current['WebhookURL'],
|
||||
'AuthenticationType': 'None',
|
||||
'Handshake': current.get('Handshake', 'tNO'),
|
||||
'VerifyCertificate': current.get('VerifyCertificate', 'tNO'),
|
||||
'EventCollection': [
|
||||
{'BusinessObject': e['BusinessObject'], 'TransactionType': e['TransactionType']}
|
||||
for e in current['EventCollection']
|
||||
],
|
||||
}
|
||||
print(json.dumps(body))
|
||||
")
|
||||
|
||||
echo "Pausando suscripción '$WEBHOOK_ID'..."
|
||||
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||
-X POST "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')/Pause"
|
||||
|
||||
echo "Eliminando suscripción '$WEBHOOK_ID'..."
|
||||
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||
-X DELETE "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')"
|
||||
|
||||
echo "Recreando '$WEBHOOK_ID' con AuthenticationType: None..."
|
||||
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||
-X POST "$SERVICE_LAYER_URL/EventSubscriptions" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$NEW_BODY"
|
||||
|
||||
# El POST de creación ignora el VerifyCertificate que mandamos y siempre
|
||||
# arranca en tYES -> hace falta un PATCH aparte para volver a ponerlo en tNO.
|
||||
echo "Ajustando VerifyCertificate: tNO..."
|
||||
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||
-X PATCH "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"VerifyCertificate": "tNO"}'
|
||||
|
||||
if grep -q '^HMAC_SECRET_KEY=' "$ENV_FILE"; then
|
||||
sed -i.bak "s|^HMAC_SECRET_KEY=.*|HMAC_SECRET_KEY=|" "$ENV_FILE"
|
||||
rm -f "$ENV_FILE.bak"
|
||||
echo
|
||||
echo "HMAC_SECRET_KEY vaciado en $ENV_FILE"
|
||||
fi
|
||||
|
||||
echo "Listo. Reiniciá la API (npm run start) para que deje de exigir la firma."
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# Activa AuthenticationType: HMAC en la suscripción de SAP, sincronizado
|
||||
# con HMAC_SECRET_KEY de .env (lo genera si todavía no existe).
|
||||
#
|
||||
# El Service Layer no permite cambiar AuthenticationType con PATCH sobre
|
||||
# una suscripción existente -> hay que Pause + Delete + volver a crearla
|
||||
# (POST), reusando su WebhookURL/Handshake/EventCollection actuales.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
ENV_FILE=".env"
|
||||
[ -f "$ENV_FILE" ] || { echo "No existe $ENV_FILE (copiá .env.example primero)"; exit 1; }
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
if [ -z "${HMAC_SECRET_KEY:-}" ]; then
|
||||
HMAC_SECRET_KEY="$(openssl rand -base64 32)"
|
||||
if grep -q '^HMAC_SECRET_KEY=' "$ENV_FILE"; then
|
||||
sed -i.bak "s|^HMAC_SECRET_KEY=.*|HMAC_SECRET_KEY=$HMAC_SECRET_KEY|" "$ENV_FILE"
|
||||
rm -f "$ENV_FILE.bak"
|
||||
else
|
||||
echo "HMAC_SECRET_KEY=$HMAC_SECRET_KEY" >> "$ENV_FILE"
|
||||
fi
|
||||
echo "Se generó un HMAC_SECRET_KEY nuevo y se guardó en $ENV_FILE"
|
||||
fi
|
||||
|
||||
: "${SERVICE_LAYER_URL:?Falta SERVICE_LAYER_URL en .env}"
|
||||
: "${COMPANY_DB:?Falta COMPANY_DB en .env}"
|
||||
: "${SL_USER:?Falta SL_USER en .env}"
|
||||
: "${SL_PASS:?Falta SL_PASS en .env}"
|
||||
: "${WEBHOOK_ID:?Falta WEBHOOK_ID en .env}"
|
||||
|
||||
COOKIE_JAR="$(mktemp)"
|
||||
trap 'rm -f "$COOKIE_JAR"' EXIT
|
||||
|
||||
echo "Login en Service Layer ($SERVICE_LAYER_URL, compañía $COMPANY_DB)..."
|
||||
curl -sk -c "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||
-X POST "$SERVICE_LAYER_URL/Login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"CompanyDB\":\"$COMPANY_DB\",\"UserName\":\"$SL_USER\",\"Password\":\"$SL_PASS\"}"
|
||||
|
||||
echo "Leyendo configuración actual de '$WEBHOOK_ID'..."
|
||||
CURRENT=$(curl -sk -b "$COOKIE_JAR" "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')")
|
||||
|
||||
NEW_BODY=$(echo "$CURRENT" | HMAC_KEY="$HMAC_SECRET_KEY" python3 -c "
|
||||
import json, os, sys
|
||||
current = json.load(sys.stdin)
|
||||
cred = json.dumps({'Key': os.environ['HMAC_KEY'], 'Algorithm': 'hmacsha256'})
|
||||
body = {
|
||||
'WebhookID': current['WebhookID'],
|
||||
'WebhookURL': current['WebhookURL'],
|
||||
'AuthenticationType': 'HMAC',
|
||||
'AuthenticationCred': cred,
|
||||
'Handshake': current.get('Handshake', 'tNO'),
|
||||
'VerifyCertificate': current.get('VerifyCertificate', 'tNO'),
|
||||
'EventCollection': [
|
||||
{'BusinessObject': e['BusinessObject'], 'TransactionType': e['TransactionType']}
|
||||
for e in current['EventCollection']
|
||||
],
|
||||
}
|
||||
print(json.dumps(body))
|
||||
")
|
||||
|
||||
echo "Pausando suscripción '$WEBHOOK_ID'..."
|
||||
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||
-X POST "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')/Pause"
|
||||
|
||||
echo "Eliminando suscripción '$WEBHOOK_ID'..."
|
||||
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||
-X DELETE "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')"
|
||||
|
||||
echo "Recreando '$WEBHOOK_ID' con AuthenticationType: HMAC..."
|
||||
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||
-X POST "$SERVICE_LAYER_URL/EventSubscriptions" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$NEW_BODY"
|
||||
|
||||
# El POST de creación ignora el VerifyCertificate que mandamos y siempre
|
||||
# arranca en tYES -> hace falta un PATCH aparte para volver a ponerlo en
|
||||
# tNO (si no, SAP intenta validar nuestro cert autofirmado y falla con
|
||||
# "SSL Certificate Untrusted", dejando la suscripción Inactive).
|
||||
echo "Ajustando VerifyCertificate: tNO..."
|
||||
curl -sk -b "$COOKIE_JAR" -o /dev/null -w " -> HTTP %{http_code}\n" \
|
||||
-X PATCH "$SERVICE_LAYER_URL/EventSubscriptions('$WEBHOOK_ID')" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"VerifyCertificate": "tNO"}'
|
||||
|
||||
echo
|
||||
echo "Listo. Reiniciá la API (npm run start) para que tome el HMAC_SECRET_KEY de .env."
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { WebhooksModule } from './webhooks/webhooks.module';
|
||||
|
||||
@Module({
|
||||
imports: [WebhooksModule],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import 'dotenv/config';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { checkDatabaseConnection } from './webhooks/webhook-events.repository';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Diagnostics');
|
||||
await checkDatabaseConnection();
|
||||
const httpsOptions = {
|
||||
key: readFileSync(join(__dirname, '..', 'certs', 'key.pem')),
|
||||
cert: readFileSync(join(__dirname, '..', 'certs', 'cert.pem')),
|
||||
};
|
||||
const app = await NestFactory.create(AppModule, { httpsOptions, rawBody: true });
|
||||
|
||||
const httpServer = app.getHttpServer();
|
||||
httpServer.on('connection', (socket) => {
|
||||
logger.log(`TCP connection recibida desde ${socket.remoteAddress}:${socket.remotePort}`);
|
||||
});
|
||||
httpServer.on('tlsClientError', (err, socket) => {
|
||||
logger.error(`TLS handshake fallido desde ${socket.remoteAddress}: ${err.message}`);
|
||||
});
|
||||
httpServer.on('secureConnection', (tlsSocket) => {
|
||||
logger.log(`TLS handshake OK desde ${tlsSocket.remoteAddress}`);
|
||||
});
|
||||
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const logger = new Logger('WebhookEventsRepository');
|
||||
|
||||
const pool = process.env.DATABASE_URL
|
||||
? new Pool({ connectionString: process.env.DATABASE_URL })
|
||||
: null;
|
||||
|
||||
// Extrae BusinessObject y Operation del "type" del CloudEvent, ej.
|
||||
// "sap.b1.Orders.Created.v1" -> { businessObject: "Orders", operation: "Created" }
|
||||
function parseType(type: string | undefined): { businessObject: string; operation: string } {
|
||||
const match = type?.match(/^sap\.b1\.([^.]+)\.([^.]+)\.v\d+$/);
|
||||
return {
|
||||
businessObject: match?.[1] ?? 'Unknown',
|
||||
operation: match?.[2] ?? 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
// Valida al arrancar si Postgres responde, para avisar temprano en el log en
|
||||
// vez de que el primer indicio sea un ECONNREFUSED al llegar el primer evento.
|
||||
// No bloquea el arranque: la API sigue funcionando sin Postgres (ver abajo).
|
||||
export async function checkDatabaseConnection(): Promise<void> {
|
||||
if (!pool) {
|
||||
logger.warn('DATABASE_URL no configurada: los eventos no se guardarán en Postgres');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
logger.log('Conexión a Postgres OK');
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`No se pudo conectar a Postgres al arrancar (¿está levantada?): ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Inserta el evento en la bandeja de entrada compartida (webhook_events) para
|
||||
// que sap-intercompany-worker lo pueda procesar. Si no hay DATABASE_URL
|
||||
// configurada, no hace nada (la API sigue funcionando igual sin Postgres).
|
||||
export async function saveWebhookEventToInbox(notification: {
|
||||
id?: string;
|
||||
type?: string;
|
||||
subject?: string;
|
||||
data?: unknown;
|
||||
}): Promise<void> {
|
||||
if (!pool || !notification.id) return;
|
||||
|
||||
const { businessObject, operation } = parseType(notification.type);
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO webhook_events (id, business_object, operation, subject, payload, status)
|
||||
VALUES ($1, $2, $3, $4, $5, 'pending')
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[notification.id, businessObject, operation, notification.subject ?? '', JSON.stringify(notification)],
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`No se pudo guardar el evento ${notification.id} en Postgres`, err as Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Head,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { Request } from 'express';
|
||||
import { saveWebhookEventToInbox } from './webhook-events.repository';
|
||||
|
||||
const EVENTS_DIR = join(process.cwd(), 'data', 'events');
|
||||
const HMAC_SECRET_KEY = process.env.HMAC_SECRET_KEY;
|
||||
|
||||
interface SapEventNotification {
|
||||
id?: string;
|
||||
specversion?: string;
|
||||
source?: string;
|
||||
type?: string;
|
||||
subject?: string;
|
||||
time?: string;
|
||||
datacontenttype?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Controller('webhooks')
|
||||
export class WebhooksController {
|
||||
private readonly logger = new Logger(WebhooksController.name);
|
||||
|
||||
@Get('sap')
|
||||
@HttpCode(200)
|
||||
validateSapWebhook(@Headers('x-b1-webhook-token') token?: string) {
|
||||
// SAP valida que el endpoint esté vivo con un GET antes de despachar
|
||||
// eventos (y lo usa también para el handshake, si Handshake: tYES).
|
||||
this.logger.log(`GET de validación/handshake recibido de SAP${token ? ` (token=${token})` : ''}`);
|
||||
return token ? { Challenge: token } : { status: 'ok' };
|
||||
}
|
||||
|
||||
@Head('sap')
|
||||
@HttpCode(200)
|
||||
checkSapWebhookAlive() {
|
||||
// SAP también puede mandar HEAD solo para chequear que el endpoint esté vivo.
|
||||
this.logger.log('HEAD de chequeo de vida recibido de SAP');
|
||||
}
|
||||
|
||||
@Get('sap/events')
|
||||
listStoredEvents() {
|
||||
if (!existsSync(EVENTS_DIR)) return [];
|
||||
|
||||
return readdirSync(EVENTS_DIR)
|
||||
.filter((file) => file.endsWith('.json'))
|
||||
.map((file) => {
|
||||
const notification = JSON.parse(
|
||||
readFileSync(join(EVENTS_DIR, file), 'utf-8'),
|
||||
) as SapEventNotification;
|
||||
return {
|
||||
id: notification.id ?? file.replace(/\.json$/, ''),
|
||||
type: notification.type,
|
||||
subject: notification.subject,
|
||||
time: notification.time,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (a.time ?? '').localeCompare(b.time ?? ''));
|
||||
}
|
||||
|
||||
@Get('sap/events/:id')
|
||||
getStoredEvent(@Param('id') id: string) {
|
||||
const filePath = join(EVENTS_DIR, `${id}.json`);
|
||||
if (!filePath.startsWith(EVENTS_DIR) || !existsSync(filePath)) {
|
||||
throw new NotFoundException(`Evento ${id} no encontrado`);
|
||||
}
|
||||
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
|
||||
@Post('sap')
|
||||
@HttpCode(200)
|
||||
async receiveSapEvent(
|
||||
@Req() req: Request,
|
||||
@Body() body: SapEventNotification | SapEventNotification[],
|
||||
@Headers('x-b1-webhook-signature') signature?: string,
|
||||
) {
|
||||
if (HMAC_SECRET_KEY) {
|
||||
this.verifyHmacSignature(req, signature);
|
||||
}
|
||||
|
||||
const notifications = Array.isArray(body) ? body : [body];
|
||||
this.logger.log(`Recibidas ${notifications.length} notificación(es) de SAP`);
|
||||
|
||||
mkdirSync(EVENTS_DIR, { recursive: true });
|
||||
for (const [index, notification] of notifications.entries()) {
|
||||
const id = notification?.id ?? `${Date.now()}-${index}`;
|
||||
const fileName = `${id}.json`;
|
||||
writeFileSync(join(EVENTS_DIR, fileName), JSON.stringify(notification, null, 2));
|
||||
await saveWebhookEventToInbox(notification);
|
||||
this.logger.log(`Evento guardado: ${fileName}`);
|
||||
}
|
||||
|
||||
return { received: true, count: notifications.length };
|
||||
}
|
||||
|
||||
private verifyHmacSignature(req: Request, signature?: string) {
|
||||
if (!signature) {
|
||||
throw new UnauthorizedException('Falta la firma HMAC (X-B1-Webhook-Signature)');
|
||||
}
|
||||
|
||||
const rawBody = (req as Request & { rawBody?: Buffer }).rawBody;
|
||||
if (!rawBody) {
|
||||
throw new UnauthorizedException('No se pudo leer el body crudo para validar la firma');
|
||||
}
|
||||
|
||||
const expectedSignature = createHmac('sha256', HMAC_SECRET_KEY as string)
|
||||
.update(rawBody)
|
||||
.digest('base64');
|
||||
|
||||
const received = Buffer.from(signature);
|
||||
const expected = Buffer.from(expectedSignature);
|
||||
const isValid =
|
||||
received.length === expected.length && timingSafeEqual(received, expected);
|
||||
|
||||
if (!isValid) {
|
||||
this.logger.warn('Firma HMAC inválida recibida en /webhooks/sap');
|
||||
throw new UnauthorizedException('Firma HMAC inválida');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WebhooksController } from './webhooks.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [WebhooksController],
|
||||
})
|
||||
export class WebhooksModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
@url=https://sap10:50000/b1s/v2
|
||||
|
||||
###
|
||||
# @no-reject-unauthorized
|
||||
POST {{url}}/Login
|
||||
Content-Type : application/json
|
||||
|
||||
{
|
||||
"CompanyDB":"SBODemoCL",
|
||||
"UserName": "manager",
|
||||
"Password":"mngr"
|
||||
}
|
||||
|
||||
###
|
||||
# @no-reject-unauthorized
|
||||
GET {{url}}/CompanyService_GetAdminInfo
|
||||
|
||||
|
||||
###
|
||||
# @no-reject-unauthorized
|
||||
POST {{url}}/CompanyService_UpdateAdminInfo
|
||||
Content-Type : application/json
|
||||
|
||||
{
|
||||
"AdminInfo":{
|
||||
"EnableWebhook": "tYES"
|
||||
}
|
||||
}
|
||||
|
||||
###
|
||||
# @no-reject-unauthorized
|
||||
GET {{url}}/EventSubscriptionsService_GetEventCatalog
|
||||
|
||||
|
||||
###
|
||||
# @no-reject-unauthorized
|
||||
POST {{url}}/EventSubscriptions
|
||||
Content-Type : application/json
|
||||
|
||||
{
|
||||
"WebhookID": "SapWebhookApiTest",
|
||||
"WebhookURL": "https://192.168.1.166:3000/webhooks/sap",
|
||||
"AuthenticationType": "None",
|
||||
"Handshake": "tNO",
|
||||
"EventCollection": [
|
||||
{
|
||||
"BusinessObject": "Orders",
|
||||
"TransactionType": "Created"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
###
|
||||
# @no-reject-unauthorized
|
||||
GET {{url}}/EventSubscriptions
|
||||
|
||||
###
|
||||
# ⚠ El Service Layer NO permite cambiar AuthenticationType con PATCH sobre
|
||||
# una suscripción existente — hay que Pause + Delete (arriba/abajo) y
|
||||
# volver a crearla con este POST. AuthenticationCred para HMAC no es el
|
||||
# secreto pelado: es un string con JSON embebido {"Key": "...", "Algorithm": "hmacsha256"}.
|
||||
# Con esta config, SAP firma cada POST con HMAC-SHA256 usando "Key" y lo
|
||||
# manda en el header X-B1-Webhook-Signature.
|
||||
# @no-reject-unauthorized
|
||||
POST {{url}}/EventSubscriptions
|
||||
Content-Type : application/json
|
||||
|
||||
{
|
||||
"WebhookID": "SapWebhookApiTest",
|
||||
"WebhookURL": "https://192.168.1.166:3000/webhooks/sap",
|
||||
"VerifyCertificate": "tNO",
|
||||
"AuthenticationType": "HMAC",
|
||||
"AuthenticationCred": "{\"Key\": \"6Umq/cD6YQ4S8LKDOT33oDB6WifYXQFRQ4oRbIGzH0c=\", \"Algorithm\": \"hmacsha256\"}",
|
||||
"Handshake": "tNO",
|
||||
"EventCollection": [
|
||||
{
|
||||
"BusinessObject": "Orders",
|
||||
"TransactionType": "Created"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
###
|
||||
# @no-reject-unauthorized
|
||||
DELETE {{url}}/EventSubscriptions('SapWebhookApiTest')
|
||||
|
||||
###
|
||||
# To remove an event (such as Invoices.Updated) from the subscription,
|
||||
# send a PATCH request with the updated EventCollection that excludes
|
||||
# the event you want to remove. Include the special header
|
||||
# B1S-ReplaceCollectionsOnPatch to indicate a full replacement of the
|
||||
# collection.
|
||||
# @no-reject-unauthorized
|
||||
PATCH {{url}}/EventSubscriptions('SapWebhookApiTest')
|
||||
B1S-ReplaceCollectionsOnPatch: true
|
||||
Content-Type : application/json
|
||||
|
||||
{
|
||||
"EventCollection": [
|
||||
{
|
||||
"BusinessObject": "BusinessPartners",
|
||||
"TransactionType": "Created"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "BusinessPartners",
|
||||
"TransactionType": "Updated"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "BusinessPartners",
|
||||
"TransactionType": "Deleted"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "Orders",
|
||||
"TransactionType": "Created"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "ProfitCenters",
|
||||
"TransactionType": "Created"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "ProfitCenters",
|
||||
"TransactionType": "Updated"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "ProfitCenters",
|
||||
"TransactionType": "Deleted"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "SalesPersons",
|
||||
"TransactionType": "Created"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "SalesPersons",
|
||||
"TransactionType": "Updated"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "SalesPersons",
|
||||
"TransactionType": "Deleted"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "ChartOfAccounts",
|
||||
"TransactionType": "Created"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "ChartOfAccounts",
|
||||
"TransactionType": "Updated"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "ChartOfAccounts",
|
||||
"TransactionType": "Deleted"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "Items",
|
||||
"TransactionType": "Created"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "Items",
|
||||
"TransactionType": "Updated"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "Items",
|
||||
"TransactionType": "Deleted"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "JournalEntries",
|
||||
"TransactionType": "Created"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "JournalEntries",
|
||||
"TransactionType": "Updated"
|
||||
},
|
||||
{
|
||||
"BusinessObject": "JournalEntries",
|
||||
"TransactionType": "Cancelled"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
###
|
||||
# If a webhook subscription is deactivated due to errors, such as
|
||||
# repeated delivery failures, you can resume it by sending a POST request
|
||||
# to the EventSubscriptions('{WebhookID}')/Resume endpoint.
|
||||
# @no-reject-unauthorized
|
||||
POST {{url}}/EventSubscriptions('SapWebhookApiTest')/Resume
|
||||
|
||||
###
|
||||
# To pause an active event subscription, send a POST request to the EventSubscriptions('{WebhookID}')/Pause endpoint.
|
||||
# @no-reject-unauthorized
|
||||
POST {{url}}/EventSubscriptions('SapWebhookApiTest')/Pause
|
||||
|
||||
###
|
||||
# If a webhook subscription requires handshake verification, you can manually initiate the handshake process by sending a POST request to the EventSubscriptions('{WebhookID}')/Handshake endpoint.
|
||||
# @no-reject-unauthorized
|
||||
POST {{url}}/EventSubscriptions('SapWebhookApiTest')/Handshake
|
||||
|
||||
###
|
||||
# To replay missed event notifications for a specific webhook subscription, send a POST request to the EventSubscriptions('{WebhookID}')/Replay endpoint:
|
||||
# @no-reject-unauthorized
|
||||
POST {{url}}/EventSubscriptions('SapWebhookApiTest')/Replay
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user