feat(api): módulo de consultas de solo lectura a la base de SAP (HANA con hdb): consultas registradas con parámetros con nombre, cola, auditoría y extensión por cliente

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-20 07:31:17 -05:00
1 parent 9118985d96
commit 3e27f370dc
21 files changed
+770 -2

No files matched your search

+2 -1
View File
@@ -4,13 +4,14 @@ import { AuthModule } from './auth/auth.module.js';
import { AppConfigModule } from './config/config.module.js';
import { DatabaseModule } from './database/database.module.js';
import { GatewayModule } from './gateway/gateway.module.js';
import { SapDbModule } from './sapdb/sapdb.module.js';
import { HealthController } from './health/health.controller.js';
import { PermissionsModule } from './permissions/permissions.module.js';
import { SapModule } from './sap/sap.module.js';
import { SlModule } from './sl/sl.module.js';
@Module({
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule],
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule, SapDbModule],
controllers: [HealthController],
})
export class AppModule {}
+31
View File
@@ -57,6 +57,37 @@ describe('validateEnv', () => {
expect(validateEnv({ ...sap, SAPB1_SL_VERIFY_SSL: 'false' }).SAPB1_SL_VERIFY_SSL).toBe(false);
});
describe('base de datos de SAP', () => {
const db = { SAPDB_ENGINE: 'hana', SAPDB_HOST: 'hana.local', SAPDB_USER: 'lector', SAPDB_PASSWORD: 'p', SAPDB_SCHEMA: 'SBO_EMPRESA' };
it('es opcional: sin motor queda sin configurar y con valores por defecto', () => {
const env = validateEnv(sap);
expect(env.SAPDB_ENGINE).toBeUndefined();
expect(env.SAPDB_PORT).toBe(30015);
expect(env.SAPDB_TLS).toBe(false);
expect(env.SAPDB_TIMEOUT).toBe(30);
expect(env.SAPDB_MAX_CONCURRENCY).toBe(2);
expect(env.SAPDB_MAX_ROWS).toBe(5000);
});
it('con motor pide servidor, usuario, clave y esquema', () => {
expect(() => validateEnv({ ...sap, SAPDB_ENGINE: 'hana' })).toThrow(/SAPDB_HOST/);
expect(validateEnv({ ...sap, ...db }).SAPDB_ENGINE).toBe('hana');
});
it('el esquema solo admite letras, dígitos y «_» (se escribe en un SET SCHEMA)', () => {
expect(() => validateEnv({ ...sap, ...db, SAPDB_SCHEMA: 'X"; DROP' })).toThrow(/SAPDB_SCHEMA/);
});
it('un motor que aún no existe se rechaza', () => {
expect(() => validateEnv({ ...sap, ...db, SAPDB_ENGINE: 'mssql' })).toThrow(/SAPDB_ENGINE/);
});
it('no se permite desactivar la validación SSL en producción con TLS', () => {
expect(() => validateEnv({ ...sap, ...db, NODE_ENV: 'production', SAPDB_TLS: 'true', SAPDB_VERIFY_SSL: 'false' })).toThrow(/SAPDB_VERIFY_SSL/);
});
});
describe('API Gateway', () => {
it('es opcional: sin URL queda sin configurar y con valores por defecto', () => {
const env = validateEnv(sap);
+32
View File
@@ -65,6 +65,29 @@ export const envSchema = z
.enum(['true', 'false'])
.default('true')
.transform((v) => v === 'true'),
// --- Base de datos de SAP B1 (consultas de solo lectura registradas): opcional; sin motor el módulo queda sin configurar ---
SAPDB_ENGINE: z.preprocess(emptyToUndefined, z.enum(['hana']).optional()),
SAPDB_HOST: z.preprocess(emptyToUndefined, z.string().optional()),
SAPDB_PORT: z.coerce.number().int().min(1).max(65535).default(30015),
SAPDB_USER: z.preprocess(emptyToUndefined, z.string().optional()),
SAPDB_PASSWORD: z.preprocess(emptyToUndefined, z.string().optional()),
// Esquema de la empresa (en HANA, el nombre de la base: `SBO_TAGUMEDICA_21`).
SAPDB_SCHEMA: z.preprocess(emptyToUndefined, z.string().regex(/^\w+$/, 'solo letras, dígitos y «_»').optional()),
SAPDB_TLS: z
.enum(['true', 'false'])
.default('false')
.transform((v) => v === 'true'),
SAPDB_CA_FILE: z.preprocess(emptyToUndefined, z.string().optional()),
SAPDB_VERIFY_SSL: z
.enum(['true', 'false'])
.default('true')
.transform((v) => v === 'true'),
// Espera máxima (segundos) de cada consulta.
SAPDB_TIMEOUT: z.coerce.number().positive().default(30),
// Consultas que se ejecutan a la vez; el resto espera su turno (la base de SAP no es para esto).
SAPDB_MAX_CONCURRENCY: z.coerce.number().int().positive().default(2),
// Tope de filas de cualquier consulta; lo que pase se recorta.
SAPDB_MAX_ROWS: z.coerce.number().int().positive().default(5000),
SAPB1_SL_VERIFY_SSL: z
.enum(['true', 'false'])
.default('true')
@@ -81,6 +104,15 @@ export const envSchema = z
if (env.APIGATEWAY_CA_FILE && !existsSync(resolve(env.APIGATEWAY_CA_FILE))) {
ctx.addIssue({ code: 'custom', path: ['APIGATEWAY_CA_FILE'], message: `no existe el archivo ${resolve(env.APIGATEWAY_CA_FILE)}` });
}
if (env.SAPDB_CA_FILE && !existsSync(resolve(env.SAPDB_CA_FILE))) {
ctx.addIssue({ code: 'custom', path: ['SAPDB_CA_FILE'], message: `no existe el archivo ${resolve(env.SAPDB_CA_FILE)}` });
}
if (env.SAPDB_ENGINE && !(env.SAPDB_HOST && env.SAPDB_USER && env.SAPDB_PASSWORD && env.SAPDB_SCHEMA)) {
ctx.addIssue({ code: 'custom', path: ['SAPDB_ENGINE'], message: 'con SAPDB_ENGINE hacen falta SAPDB_HOST, SAPDB_USER, SAPDB_PASSWORD y SAPDB_SCHEMA' });
}
if (env.NODE_ENV === 'production' && env.SAPDB_TLS && !env.SAPDB_VERIFY_SSL) {
ctx.addIssue({ code: 'custom', path: ['SAPDB_VERIFY_SSL'], message: 'no se permite desactivar la validación SSL en producción; use SAPDB_CA_FILE' });
}
if (env.APIGATEWAY_URL && !(env.APIGATEWAY_USER && env.APIGATEWAY_PASSWORD && env.APIGATEWAY_COMPANYDB)) {
ctx.addIssue({ code: 'custom', path: ['APIGATEWAY_URL'], message: 'con APIGATEWAY_URL hacen falta APIGATEWAY_USER, APIGATEWAY_PASSWORD y APIGATEWAY_COMPANYDB' });
}
+73
View File
@@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import hdb from 'hdb';
import type { SapDbAdapter, SapDbRow } from './sapdb.types.js';
export interface HanaOptions {
host: string;
port: number;
user: string;
password: string;
schema: string;
tls: boolean;
caFile?: string;
verifySsl: boolean;
}
/**
* Adaptador de SAP HANA con `hdb` (JavaScript puro, sin módulos nativos). Una conexión por consulta (la cola del
* servicio limita cuántas hay a la vez): se abre, fija el esquema de la empresa, ejecuta y se cierra. Nunca confirma
* nada (`autoCommit` apagado); el usuario de la base debe ser de solo lectura.
*/
export class HanaAdapter implements SapDbAdapter {
constructor(private readonly options: HanaOptions) {}
query(sql: string, args: readonly unknown[], opts: { timeoutMs: number }): Promise<SapDbRow[]> {
const o = this.options;
return new Promise<SapDbRow[]>((done, fail) => {
const client = hdb.createClient({
host: o.host,
port: o.port,
user: o.user,
password: o.password,
useTLS: o.tls,
...(o.tls && o.caFile ? { ca: readFileSync(resolve(o.caFile), 'utf8') } : {}),
...(o.tls ? { rejectUnauthorized: o.verifySsl } : {}),
});
let settled = false;
const finish = (err: Error | null, rows?: SapDbRow[]): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
client.end();
} catch {
/* la conexión ya se cerró */
}
if (err) fail(err);
else done(rows ?? []);
};
const timer = setTimeout(() => {
client.destroy();
finish(new Error(`La base de datos no respondió en ${Math.round(opts.timeoutMs / 1000)} s`));
}, opts.timeoutMs);
client.on('error', (err: Error) => finish(err));
client.connect((err?: Error) => {
if (err) return finish(err);
client.setAutoCommit(false);
// el esquema ya se validó al leer la configuración (solo letras, dígitos y «_»)
client.exec(`SET SCHEMA "${o.schema}"`, (e?: Error) => {
if (e) return finish(e);
client.prepare(sql, (pe: Error | undefined, stmt) => {
if (pe || !stmt) return finish(pe ?? new Error('No se pudo preparar la consulta'));
stmt.exec([...args], (xe: Error | undefined, rows?: unknown) => {
stmt.drop(() => undefined);
finish(xe ?? null, Array.isArray(rows) ? (rows as SapDbRow[]) : []);
});
});
});
});
});
}
}
+18
View File
@@ -0,0 +1,18 @@
/** Lo mínimo de `hdb` (sin tipos propios) que usa el adaptador de HANA. */
declare module 'hdb' {
interface Statement {
exec(args: unknown[], cb: (err?: Error, rows?: unknown) => void): void;
drop(cb: () => void): void;
}
interface Client {
connect(cb: (err?: Error) => void): void;
setAutoCommit(on: boolean): void;
exec(sql: string, cb: (err?: Error) => void): void;
prepare(sql: string, cb: (err: Error | undefined, stmt?: Statement) => void): void;
on(event: 'error', cb: (err: Error) => void): void;
end(): void;
destroy(): void;
}
const hdb: { createClient(options: Record<string, unknown>): Client };
export default hdb;
}
+36
View File
@@ -0,0 +1,36 @@
import { assertReadOnly, paramNames } from './sapdb-sql.js';
import { CLIENT_SAPDB_QUERIES } from './sapdb.extensions.js';
import type { SapDbQuery } from './sapdb.types.js';
/** Consultas del proyecto base: solo una de comprobación (la base responde). Las de negocio van en `sapdb.extensions.ts`. */
export const BASE_SAPDB_QUERIES: readonly SapDbQuery[] = [
{
name: 'ping',
title: 'Comprobar la conexión con la base de datos',
sql: { hana: 'SELECT 1 AS "ok" FROM "DUMMY"' },
params: {},
menu: null,
},
];
/** Comprueba que los nombres no se repitan, que el SQL sea de solo lectura y que use justo los parámetros declarados. Lanza si no. */
export function validateQueries(queries: readonly SapDbQuery[]): readonly SapDbQuery[] {
const seen = new Set<string>();
for (const q of queries) {
if (!/^[a-z0-9][a-z0-9-]{0,59}$/.test(q.name)) throw new Error(`Nombre de consulta inválido: ${q.name}`);
if (seen.has(q.name)) throw new Error(`Consulta repetida: ${q.name}`);
seen.add(q.name);
const engines = Object.entries(q.sql);
if (!engines.length) throw new Error(`La consulta ${q.name} no tiene SQL`);
for (const [engine, sql] of engines) {
assertReadOnly(`${q.name} (${engine})`, sql);
const used = new Set(paramNames(sql));
const declared = new Set(Object.keys(q.params));
for (const n of used) if (!declared.has(n)) throw new Error(`La consulta ${q.name} (${engine}) usa :${n} sin declararlo`);
for (const n of declared) if (!used.has(n)) throw new Error(`La consulta ${q.name} (${engine}) declara ${n} y no lo usa`);
}
}
return queries;
}
export const SAPDB_QUERIES: ReadonlyMap<string, SapDbQuery> = new Map(validateQueries([...BASE_SAPDB_QUERIES, ...CLIENT_SAPDB_QUERIES]).map((q) => [q.name, q]));
+67
View File
@@ -0,0 +1,67 @@
import { BadRequestException } from '@nestjs/common';
import { assertReadOnly, bindNamed, paramNames, parseParams } from './sapdb-sql.js';
import { validateQueries } from './sapdb-queries.js';
import type { SapDbQuery } from './sapdb.types.js';
describe('assertReadOnly', () => {
it('acepta SELECT y WITH, con comentarios y literales que parecen verbos', () => {
expect(() => assertReadOnly('q', 'SELECT "UpdateDate", \'delete me\' AS "x" FROM "OBTN" -- drop table\nWHERE 1 = 1')).not.toThrow();
expect(() => assertReadOnly('q', 'WITH a AS (SELECT 1 AS "n" FROM "DUMMY") SELECT * FROM a')).not.toThrow();
expect(() => assertReadOnly('q', "SELECT REPLACE(\"ItemName\", 'a', 'b') AS \"n\" FROM \"OITM\"")).not.toThrow();
});
it('rechaza lo que escribe, varias sentencias y lo que no empieza por SELECT', () => {
for (const sql of ['DELETE FROM "OITM"', 'UPDATE "OITM" SET "a" = 1', 'SELECT 1 FROM "DUMMY"; DROP TABLE "OITM"', 'CALL "SP"()', 'SELECT 1 FROM "DUMMY" /* x */ ; SELECT 2 FROM "DUMMY"', 'WITH x AS (SELECT 1 AS "a" FROM "DUMMY") INSERT INTO "T" SELECT * FROM x']) {
expect(() => assertReadOnly('q', sql), sql).toThrow();
}
});
});
describe('parámetros con nombre', () => {
it('los nombres salen en orden y se ignoran los de literales, identificadores, comentarios y «::»', () => {
expect(paramNames('SELECT :a, \':no\', ":tampoco", x::int FROM t WHERE b = :b AND c = :a -- :fuera')).toEqual(['a', 'b', 'a']);
});
it('se cambian por «?» y los argumentos van en el mismo orden (un nombre repetido se repite)', () => {
const r = bindNamed('SELECT * FROM t WHERE a = :a AND b = :b AND c = :a AND d = \':a\'', { a: 1, b: 'x' });
expect(r.sql).toBe("SELECT * FROM t WHERE a = ? AND b = ? AND c = ? AND d = ':a'");
expect(r.args).toEqual([1, 'x', 1]);
});
it('un valor que parece SQL viaja como argumento, nunca dentro del texto', () => {
const r = bindNamed('SELECT 1 FROM t WHERE a = :a', { a: "x'; DROP TABLE t; --" });
expect(r.sql).toBe('SELECT 1 FROM t WHERE a = ?');
expect(r.args).toEqual(["x'; DROP TABLE t; --"]);
});
});
describe('parseParams', () => {
const q: SapDbQuery = {
name: 'demo',
title: 'Demo',
sql: { hana: 'SELECT 1 FROM "DUMMY" WHERE :lote = :lote AND :n = :n AND :f = :f AND :o = :o' },
params: { lote: { type: 'string' }, n: { type: 'number' }, f: { type: 'date' }, o: { type: 'string', required: false } },
menu: null,
};
it('comprueba tipos, exige los obligatorios y deja los opcionales en null', () => {
expect(parseParams(q, { lote: ' L1 ', n: '3', f: '2026-10-20' })).toEqual({ lote: 'L1', n: 3, f: '2026-10-20', o: null });
expect(() => parseParams(q, { n: 1, f: '2026-10-20' })).toThrow(BadRequestException);
expect(() => parseParams(q, { lote: 'a', n: 'x', f: '2026-10-20' })).toThrow(/número/);
expect(() => parseParams(q, { lote: 'a', n: 1, f: '20/10/2026' })).toThrow(/yyyy-MM-dd/);
expect(() => parseParams(q, { lote: 'a', n: 1, f: '2026-10-20', otro: 'x' })).toThrow(/desconocidos/);
});
});
describe('validateQueries', () => {
const base: SapDbQuery = { name: 'a', title: 'A', sql: { hana: 'SELECT 1 AS "x" FROM "DUMMY"' }, params: {}, menu: null };
it('rechaza nombres repetidos, SQL que escribe y parámetros sin declarar o sin usar', () => {
expect(() => validateQueries([base, base])).toThrow(/repetida/);
expect(() => validateQueries([{ ...base, sql: { hana: 'DELETE FROM "T"' } }])).toThrow(/SELECT/);
expect(() => validateQueries([{ ...base, sql: { hana: 'SELECT :x FROM "DUMMY"' } }])).toThrow(/sin declararlo/);
expect(() => validateQueries([{ ...base, params: { x: { type: 'string' } } }])).toThrow(/no lo usa/);
expect(() => validateQueries([{ ...base, name: 'Malo Nombre' }])).toThrow(/inválido/);
expect(validateQueries([base])).toHaveLength(1);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { BadRequestException } from '@nestjs/common';
import type { SapDbParamType, SapDbQuery } from './sapdb.types.js';
/** Quita comentarios y el contenido de los literales (`'…'`) y de los identificadores entre comillas (`"…"`), para revisar solo el SQL. */
function skeleton(sql: string): string {
let out = '';
let i = 0;
while (i < sql.length) {
const c = sql[i];
if (c === '-' && sql[i + 1] === '-') {
while (i < sql.length && sql[i] !== '\n') i++;
} else if (c === '/' && sql[i + 1] === '*') {
const end = sql.indexOf('*/', i + 2);
i = end === -1 ? sql.length : end + 2;
} else if (c === "'" || c === '"') {
out += c + c;
i++;
while (i < sql.length && !(sql[i] === c && sql[i + 1] !== c)) i += sql[i] === c ? 2 : 1;
i++;
} else {
out += c;
i++;
}
}
return out;
}
const FORBIDDEN = /\b(insert|update|delete|merge|upsert|drop|alter|create|truncate|grant|revoke|call|exec|execute|commit|rollback)\b/i;
/** Una consulta registrada solo puede leer: un único `SELECT` o `WITH`, sin `;` ni verbos que escriben. Lanza si no cumple. */
export function assertReadOnly(name: string, sql: string): void {
const s = skeleton(sql).trim();
if (!/^(select|with)\b/i.test(s)) throw new Error(`La consulta ${name} debe empezar por SELECT o WITH`);
if (s.includes(';')) throw new Error(`La consulta ${name} no puede llevar «;»`);
const bad = FORBIDDEN.exec(s);
if (bad) throw new Error(`La consulta ${name} no puede usar «${bad[1].toUpperCase()}» (solo lectura)`);
}
/** Los `:nombre` que usa un SQL fuera de literales e identificadores, en orden de aparición. */
export function paramNames(sql: string): string[] {
const out: string[] = [];
const s = skeleton(sql);
for (const m of s.matchAll(/(?<![:\w]):([A-Za-z_]\w*)/g)) out.push(m[1]);
return out;
}
/** Cambia los `:nombre` por `?` y arma los argumentos en el mismo orden (un nombre repetido se repite). */
export function bindNamed(sql: string, values: Readonly<Record<string, unknown>>): { sql: string; args: unknown[] } {
const args: unknown[] = [];
let out = '';
let i = 0;
while (i < sql.length) {
const c = sql[i];
if (c === '-' && sql[i + 1] === '-') {
const end = sql.indexOf('\n', i);
const stop = end === -1 ? sql.length : end;
out += sql.slice(i, stop);
i = stop;
} else if (c === '/' && sql[i + 1] === '*') {
const end = sql.indexOf('*/', i + 2);
const stop = end === -1 ? sql.length : end + 2;
out += sql.slice(i, stop);
i = stop;
} else if (c === "'" || c === '"') {
let j = i + 1;
while (j < sql.length && !(sql[j] === c && sql[j + 1] !== c)) j += sql[j] === c ? 2 : 1;
out += sql.slice(i, j + 1);
i = j + 1;
} else if (c === ':' && sql[i - 1] !== ':' && /[A-Za-z_]/.test(sql[i + 1] ?? '')) {
const m = /^[A-Za-z_]\w*/.exec(sql.slice(i + 1))!;
args.push(values[m[0]] ?? null);
out += '?';
i += 1 + m[0].length;
} else {
out += c;
i++;
}
}
return { sql: out, args };
}
const DATE = /^\d{4}-\d{2}-\d{2}$/;
function coerce(name: string, type: SapDbParamType, raw: unknown): string | number {
if (type === 'number') {
const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN;
if (!Number.isFinite(n)) throw new BadRequestException(`El parámetro ${name} debe ser un número`);
return n;
}
if (typeof raw !== 'string') throw new BadRequestException(`El parámetro ${name} debe ser un texto`);
const text = raw.trim();
if (text.length > 200) throw new BadRequestException(`El parámetro ${name} es demasiado largo`);
if (type === 'date' && !DATE.test(text)) throw new BadRequestException(`El parámetro ${name} debe ser una fecha yyyy-MM-dd`);
return text;
}
/** Los valores recibidos según los parámetros declarados: tipo comprobado, obligatorios exigidos y sin nombres de más. */
export function parseParams(query: SapDbQuery, raw: Readonly<Record<string, unknown>>): Record<string, string | number | null> {
const unknown = Object.keys(raw).filter((k) => !(k in query.params));
if (unknown.length) throw new BadRequestException(`Parámetros desconocidos: ${unknown.join(', ')}`);
const out: Record<string, string | number | null> = {};
for (const [name, def] of Object.entries(query.params)) {
const v = raw[name];
if (v === undefined || v === null || v === '') {
if (def.required ?? true) throw new BadRequestException(`Falta el parámetro ${name}`);
out[name] = null;
} else {
out[name] = coerce(name, def.type, v);
}
}
return out;
}
+30
View File
@@ -0,0 +1,30 @@
import { Body, Controller, Get, HttpCode, Param, Post, Req } from '@nestjs/common';
import { z } from 'zod';
import type { AuthenticatedRequest } from '../auth/auth.types.js';
import { ZodValidationPipe } from '../common/zod-validation.pipe.js';
import { SapDbService } from './sapdb.service.js';
const nameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,59}$/, 'Nombre de consulta inválido');
const bodySchema = z.object({ params: z.record(z.string(), z.union([z.string().max(200), z.number()])).default({}) });
type Body_ = z.infer<typeof bodySchema>;
/** Consultas de solo lectura a la base de datos de SAP (las registradas; el permiso lo declara cada una). */
@Controller('sapdb')
export class SapDbController {
constructor(private readonly sapdb: SapDbService) {}
@Get('queries')
queries(@Req() req: AuthenticatedRequest) {
return this.sapdb.available(req.user);
}
@Post('queries/:name')
@HttpCode(200)
run(
@Req() req: AuthenticatedRequest,
@Param('name', new ZodValidationPipe(nameSchema)) name: string,
@Body(new ZodValidationPipe(bodySchema)) body: Body_,
) {
return this.sapdb.run(req.user, name, body.params, req.ip ?? '');
}
}
+7
View File
@@ -0,0 +1,7 @@
import type { SapDbQuery } from './sapdb.types.js';
/**
* Consultas a la base de SAP que agrega la rama de cada cliente (vacío en `main`): el SQL depende de sus tablas y de
* sus campos de usuario. Cada una declara su SQL por motor (`hana`), sus parámetros con nombre y el permiso de menú.
*/
export const CLIENT_SAPDB_QUERIES: readonly SapDbQuery[] = [];
+36
View File
@@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module.js';
import { AppConfigService } from '../config/app-config.service.js';
import { PermissionsModule } from '../permissions/permissions.module.js';
import { HanaAdapter } from './hana.adapter.js';
import { SapDbController } from './sapdb.controller.js';
import { SapDbService } from './sapdb.service.js';
import { SAPDB_ADAPTER, type SapDbAdapter } from './sapdb.types.js';
/** Consultas de solo lectura a la base de SAP B1 (hoy HANA): su propia cola y sus propias consultas registradas. */
@Module({
imports: [AuditModule, PermissionsModule],
controllers: [SapDbController],
providers: [
{
provide: SAPDB_ADAPTER,
inject: [AppConfigService],
// sin motor configurado no hay adaptador: el servicio responde 503 y el resto de la API sigue igual
useFactory: (config: AppConfigService): SapDbAdapter | null =>
config.get('SAPDB_ENGINE') === 'hana'
? new HanaAdapter({
host: config.get('SAPDB_HOST')!,
port: config.get('SAPDB_PORT'),
user: config.get('SAPDB_USER')!,
password: config.get('SAPDB_PASSWORD')!,
schema: config.get('SAPDB_SCHEMA')!,
tls: config.get('SAPDB_TLS'),
caFile: config.get('SAPDB_CA_FILE'),
verifySsl: config.get('SAPDB_VERIFY_SSL'),
})
: null,
},
SapDbService,
],
})
export class SapDbModule {}
@@ -0,0 +1,61 @@
import { ForbiddenException } from '@nestjs/common';
import { vi } from 'vitest';
import type { AuditService } from '../audit/audit.service.js';
import type { AuthUser } from '../auth/auth.types.js';
import type { AppConfigService } from '../config/app-config.service.js';
import type { PermissionsService } from '../permissions/permissions.service.js';
import type { SapDbAdapter, SapDbQuery } from './sapdb.types.js';
// un registro con una consulta que exige permiso de menú (la de `main` no lo exige)
vi.mock('./sapdb-queries.js', () => {
const q: SapDbQuery = {
name: 'lot-stock',
title: 'Stock de un lote',
sql: { hana: 'SELECT "WhsCode" FROM "OBTQ" WHERE "DistNumber" = :lote' },
params: { lote: { type: 'string' } },
menu: ['/inventario/lotes', '/inventario/liberar-lotes'],
};
return { SAPDB_QUERIES: new Map([[q.name, q]]) };
});
const { SapDbService } = await import('./sapdb.service.js');
const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' };
describe('permiso de una consulta', () => {
const query = vi.fn(async () => [{ WhsCode: '201' }]);
const record = vi.fn(async () => undefined);
let paths: string[];
const service = () =>
new SapDbService(
{ query } as SapDbAdapter,
{ get: (k: string) => ({ SAPDB_ENGINE: 'hana', SAPDB_MAX_CONCURRENCY: 1, SAPDB_MAX_ROWS: 100, SAPDB_TIMEOUT: 5 })[k] } as unknown as AppConfigService,
{ getPaths: async () => paths } as unknown as PermissionsService,
{ record } as unknown as AuditService,
);
beforeEach(() => {
query.mockClear();
record.mockClear();
});
it('basta el permiso de cualquiera de las rutas que declara la consulta', async () => {
paths = ['/inventario/liberar-lotes'];
expect((await service().run(user, 'lot-stock', { lote: 'L1' }, '')).rows).toEqual([{ WhsCode: '201' }]);
expect(query).toHaveBeenCalledWith('SELECT "WhsCode" FROM "OBTQ" WHERE "DistNumber" = ?', ['L1'], expect.anything());
});
it('sin permiso se deniega, se audita y no se consulta; tampoco aparece en la lista', async () => {
paths = ['/ventas/*'];
await expect(service().run(user, 'lot-stock', { lote: 'L1' }, '')).rejects.toThrow(ForbiddenException);
expect(query).not.toHaveBeenCalled();
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'db_query', outcome: 'denied', resource: 'lot-stock' }));
expect(await service().available(user)).toEqual([]);
});
it('un parámetro de más o de menos se rechaza antes de llegar a la base', async () => {
paths = ['*'];
await expect(service().run(user, 'lot-stock', {}, '')).rejects.toThrow(/Falta/);
await expect(service().run(user, 'lot-stock', { lote: 'L1', x: '1' }, '')).rejects.toThrow(/desconocidos/);
expect(query).not.toHaveBeenCalled();
});
});
+84
View File
@@ -0,0 +1,84 @@
import { BadGatewayException, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { vi } from 'vitest';
import type { AuditService } from '../audit/audit.service.js';
import type { AuthUser } from '../auth/auth.types.js';
import type { AppConfigService } from '../config/app-config.service.js';
import type { PermissionsService } from '../permissions/permissions.service.js';
import { SapDbService } from './sapdb.service.js';
import type { SapDbAdapter } from './sapdb.types.js';
const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' };
describe('SapDbService', () => {
let query: ReturnType<typeof vi.fn>;
let record: ReturnType<typeof vi.fn>;
let paths: string[];
let max: number;
const make = (adapter: SapDbAdapter | null) =>
new SapDbService(
adapter,
{ get: (k: string) => ({ SAPDB_ENGINE: 'hana', SAPDB_MAX_CONCURRENCY: max, SAPDB_MAX_ROWS: 3, SAPDB_TIMEOUT: 5 })[k] } as unknown as AppConfigService,
{ getPaths: async () => paths } as unknown as PermissionsService,
{ record } as unknown as AuditService,
);
beforeEach(() => {
query = vi.fn(async () => [{ ok: 1 }]);
record = vi.fn(async () => undefined);
paths = ['*'];
max = 2;
});
it('sin base configurada responde 503', async () => {
const s = make(null);
expect(s.configured).toBe(false);
await expect(s.run(user, 'ping', {}, '10.0.0.5')).rejects.toThrow(ServiceUnavailableException);
});
it('ejecuta una consulta registrada y audita solo los nombres de los parámetros', async () => {
const s = make({ query } as SapDbAdapter);
expect(await s.run(user, 'ping', {}, '10.0.0.5')).toEqual({ rows: [{ ok: 1 }], truncated: false });
expect(query).toHaveBeenCalledWith('SELECT 1 AS "ok" FROM "DUMMY"', [], { timeoutMs: 5000 });
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'db_query', resource: 'ping', detail: expect.objectContaining({ params: [], rows: 1 }) }));
});
it('una consulta que no existe da 404 y nunca llega a la base', async () => {
await expect(make({ query } as SapDbAdapter).run(user, 'drop-everything', {}, '')).rejects.toThrow(NotFoundException);
expect(query).not.toHaveBeenCalled();
});
it('recorta lo que pasa del tope de filas y lo avisa', async () => {
query.mockResolvedValue([{ n: 1 }, { n: 2 }, { n: 3 }, { n: 4 }]);
const r = await make({ query } as SapDbAdapter).run(user, 'ping', {}, '');
expect(r.rows).toHaveLength(3);
expect(r.truncated).toBe(true);
});
it('un error de la base se audita y sale como 502 con el mensaje', async () => {
query.mockRejectedValue(new Error('invalid table name: OXXX'));
await expect(make({ query } as SapDbAdapter).run(user, 'ping', {}, '')).rejects.toThrow(BadGatewayException);
expect(record).toHaveBeenCalledWith(expect.objectContaining({ outcome: 'error', detail: expect.objectContaining({ error: 'invalid table name: OXXX' }) }));
});
it('la cola deja pasar solo SAPDB_MAX_CONCURRENCY consultas a la vez', async () => {
max = 1;
let running = 0;
let peak = 0;
query.mockImplementation(async () => {
running++;
peak = Math.max(peak, running);
await new Promise((r) => setTimeout(r, 5));
running--;
return [];
});
const s = make({ query } as SapDbAdapter);
await Promise.all([s.run(user, 'ping', {}, ''), s.run(user, 'ping', {}, ''), s.run(user, 'ping', {}, '')]);
expect(peak).toBe(1);
expect(query).toHaveBeenCalledTimes(3);
});
it('lista las consultas disponibles', async () => {
expect(await make({ query } as SapDbAdapter).available(user)).toEqual([{ name: 'ping', title: expect.any(String) }]);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { BadGatewayException, ForbiddenException, Inject, Injectable, Logger, NotFoundException, ServiceUnavailableException, NotImplementedException } from '@nestjs/common';
import { isPathAllowed } from '@basic-erp/sap-types';
import { AuditService } from '../audit/audit.service.js';
import type { AuthUser } from '../auth/auth.types.js';
import { AppConfigService } from '../config/app-config.service.js';
import { PermissionsService } from '../permissions/permissions.service.js';
import { SAPDB_QUERIES } from './sapdb-queries.js';
import { bindNamed, parseParams } from './sapdb-sql.js';
import { SAPDB_ADAPTER, type SapDbAdapter, type SapDbQuery, type SapDbResult } from './sapdb.types.js';
/**
* Consultas de solo lectura a la base de datos de SAP B1. Solo se ejecutan las registradas (`sapdb-queries.ts` y la
* extensión del cliente), con parámetros tipados y cola propia (`SAPDB_MAX_CONCURRENCY`): la base de SAP no es un
* servicio para ráfagas. Quien consulta necesita el permiso de menú que declara la consulta.
*/
@Injectable()
export class SapDbService {
private readonly logger = new Logger(SapDbService.name);
private active = 0;
private readonly waiting: (() => void)[] = [];
constructor(
@Inject(SAPDB_ADAPTER) private readonly adapter: SapDbAdapter | null,
private readonly config: AppConfigService,
private readonly permissions: PermissionsService,
private readonly audit: AuditService,
) {}
get configured(): boolean {
return this.adapter !== null;
}
/** Las consultas que el usuario puede ejecutar (nombre y título). */
async available(user: AuthUser): Promise<{ name: string; title: string }[]> {
const paths = await this.permissions.getPaths(user.webUser);
return [...SAPDB_QUERIES.values()].filter((q) => this.allowed(paths, q)).map((q) => ({ name: q.name, title: q.title }));
}
private allowed(paths: readonly string[], q: SapDbQuery): boolean {
if (q.menu === null) return true;
return [q.menu].flat().some((m) => isPathAllowed([...paths], m));
}
private async turn<T>(work: () => Promise<T>): Promise<T> {
if (this.active >= this.config.get('SAPDB_MAX_CONCURRENCY')) await new Promise<void>((go) => this.waiting.push(go));
this.active++;
try {
return await work();
} finally {
this.active--;
this.waiting.shift()?.();
}
}
async run(user: AuthUser, name: string, rawParams: Readonly<Record<string, unknown>>, ip: string): Promise<SapDbResult> {
if (!this.adapter) throw new ServiceUnavailableException('La base de datos de SAP no está configurada (SAPDB_*)');
const query = SAPDB_QUERIES.get(name);
if (!query) throw new NotFoundException(`No existe la consulta ${name}`);
if (!this.allowed(await this.permissions.getPaths(user.webUser), query)) {
await this.audit.record({ action: 'db_query', webUser: user.webUser, ip, resource: name, outcome: 'denied' });
throw new ForbiddenException('No tiene permisos para esta consulta');
}
const sql = query.sql[this.config.get('SAPDB_ENGINE') ?? 'hana'];
if (!sql) throw new NotImplementedException(`La consulta ${name} no está disponible para este motor de base de datos`);
const values = parseParams(query, rawParams);
const bound = bindNamed(sql, values);
const cap = Math.min(query.maxRows ?? Infinity, this.config.get('SAPDB_MAX_ROWS'));
// solo los nombres de los parámetros: los valores pueden ser datos de clientes
const detail = { params: Object.keys(query.params) };
const started = Date.now();
try {
const rows = await this.turn(() => this.adapter!.query(bound.sql, bound.args, { timeoutMs: this.config.get('SAPDB_TIMEOUT') * 1000 }));
await this.audit.record({ action: 'db_query', webUser: user.webUser, ip, resource: name, detail: { ...detail, rows: rows.length, ms: Date.now() - started } });
return { rows: rows.slice(0, cap), truncated: rows.length > cap };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
this.logger.error(`Consulta ${name} falló: ${message}`);
await this.audit.record({ action: 'db_query', webUser: user.webUser, ip, resource: name, outcome: 'error', detail: { ...detail, error: message.slice(0, 300) } });
throw new BadGatewayException(`No se pudo consultar la base de datos: ${message.slice(0, 200)}`);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
/** Motores de base de datos de SAP B1 que el módulo sabe consultar (hoy solo HANA; MSSQL se suma con su adaptador). */
export type SapDbEngine = 'hana';
export type SapDbParamType = 'string' | 'number' | 'date';
/**
* Una consulta registrada: el frontend pide `name` con sus parámetros y nunca manda SQL. Lo que pasa a la base es
* siempre el SQL del registro, con los parámetros como marcadores (`:nombre`), nunca concatenados.
*/
export interface SapDbQuery {
/** Identificador estable (`lot-stock`); es lo que viaja en la URL y en la auditoría. */
name: string;
title: string;
/** El SQL por motor (solo `SELECT`/`WITH`); una consulta puede declarar solo el motor que usa su cliente. */
sql: Partial<Record<SapDbEngine, string>>;
/** Parámetros con nombre (`:warehouse` en el SQL). */
params: Readonly<Record<string, { type: SapDbParamType; required?: boolean }>>;
/** Ruta(s) de menú cuyo permiso basta para ejecutarla; `null` = cualquier sesión. */
menu: string | readonly string[] | null;
/** Tope de filas de esta consulta (no puede superar `SAPDB_MAX_ROWS`). */
maxRows?: number;
}
export type SapDbRow = Record<string, unknown>;
/** Lo que un motor implementa: ejecutar un `SELECT` con argumentos posicionales y devolver filas. */
export interface SapDbAdapter {
query(sql: string, args: readonly unknown[], opts: { timeoutMs: number }): Promise<SapDbRow[]>;
}
/** Respuesta de una consulta. */
export interface SapDbResult {
rows: SapDbRow[];
/** Hubo más filas que el tope y se recortó. */
truncated: boolean;
}
/** Token de inyección del adaptador del motor configurado (`null` si el módulo no está configurado). */
export const SAPDB_ADAPTER = Symbol('SAPDB_ADAPTER');