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:
1 parent
9118985d96
commit
3e27f370dc
21 files changed
+770
-2
No files matched your search
@@ -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[]) : []);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Vendored
+18
@@ -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;
|
||||
}
|
||||
@@ -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]));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
}
|
||||
@@ -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[] = [];
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) }]);
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
Reference in new issue
Block a user