feat: version 4.10.0 -- Conectores de datos externos y conector FoxPro

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-29 11:32:08 -05:00
parent 28444b1485
commit d0f90542e9
23 changed files with 945 additions and 9 deletions
@@ -0,0 +1,6 @@
# Detiene y quita la tarea y la regla de firewall del conector. No borra archivos.
$Tarea = 'LinkedCorp-Conector-FoxPro'
Stop-ScheduledTask -TaskName $Tarea -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $Tarea -Confirm:$false -ErrorAction SilentlyContinue
Get-NetFirewallRule -DisplayName 'LinkedCorp-Conector-FoxPro' -ErrorAction SilentlyContinue | Remove-NetFirewallRule
Write-Host 'Conector desinstalado.'
@@ -0,0 +1,9 @@
'use strict';
// Genera un token nuevo. El TOKEN se carga en el sistema (Configuración → Conexiones externas);
// en config.json del conector va SOLO el SHA-256.
const crypto = require('crypto');
const token = crypto.randomBytes(32).toString('base64url');
const hash = crypto.createHash('sha256').update(token).digest('hex');
console.log(`TOKEN (para el sistema, no se guarda aquí): ${token}`);
console.log(`tokenSha256 (para config.json): ${hash}`);
@@ -0,0 +1,42 @@
# Instala el conector FoxPro como tarea programada que arranca con Windows y se reinicia si cae.
# Ejecutar en PowerShell como administrador, desde C:\api_project\foxpro:
# powershell -ExecutionPolicy Bypass -File .\scripts\instalar-servicio.ps1
# Opcional: -Usuario 'DOMINIO\svc_conector' (cuenta con SOLO lectura sobre las carpetas de datos).
param(
[string]$Usuario = 'SYSTEM',
[string]$Clave = '',
[int]$Puerto = 8455,
[string[]]$IpsPermitidas = @('192.168.222.3', '192.168.222.40')
)
$ErrorActionPreference = 'Stop'
$App = Split-Path -Parent $PSScriptRoot
$Tarea = 'LinkedCorp-Conector-FoxPro'
# node.exe portable dentro de la carpeta (no requiere instalar Node en el servidor); si no, el del sistema.
$Node = Join-Path $App 'node\node.exe'
if (-not (Test-Path $Node)) { $Node = (Get-Command node.exe -ErrorAction Stop).Source }
if (-not (Test-Path (Join-Path $App 'config.json'))) { throw 'Falta config.json (copie config.example.json y complételo).' }
$accion = New-ScheduledTaskAction -Execute $Node -Argument "`"$App\src\server.js`"" -WorkingDirectory $App
$disparador = New-ScheduledTaskTrigger -AtStartup
$ajustes = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
if (Get-ScheduledTask -TaskName $Tarea -ErrorAction SilentlyContinue) {
Stop-ScheduledTask -TaskName $Tarea -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $Tarea -Confirm:$false
}
if ($Usuario -eq 'SYSTEM') {
Register-ScheduledTask -TaskName $Tarea -Action $accion -Trigger $disparador -Settings $ajustes -User 'SYSTEM' -RunLevel Highest | Out-Null
} else {
Register-ScheduledTask -TaskName $Tarea -Action $accion -Trigger $disparador -Settings $ajustes -User $Usuario -Password $Clave | Out-Null
}
# Firewall: el puerto solo acepta a las IPs del backend (además del filtro allowIps del propio conector).
$Regla = 'LinkedCorp-Conector-FoxPro'
Get-NetFirewallRule -DisplayName $Regla -ErrorAction SilentlyContinue | Remove-NetFirewallRule
New-NetFirewallRule -DisplayName $Regla -Direction Inbound -Protocol TCP -LocalPort $Puerto `
-RemoteAddress $IpsPermitidas -Action Allow | Out-Null
Start-ScheduledTask -TaskName $Tarea
Write-Host "Conector instalado. Tarea: $Tarea Puerto: $Puerto IPs: $($IpsPermitidas -join ', ')"
@@ -0,0 +1,124 @@
'use strict';
// Prueba de humo: crea una tabla DBF temporal, levanta el conector y valida el contrato v1.
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const { DBFFile } = require('dbffile');
const PORT = 18455;
const TOKEN = 'token-de-prueba';
async function main() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prueba-fox-'));
const data = path.join(dir, 'nomina');
fs.mkdirSync(data);
const dbf = await DBFFile.create(path.join(data, 'EMPLEADO.DBF'), [
{ name: 'CEDULA', type: 'C', size: 13 },
{ name: 'NOMBRE', type: 'C', size: 60 },
{ name: 'CARGO', type: 'C', size: 40 },
{ name: 'SUELDO', type: 'N', size: 10, decimalPlaces: 2 },
{ name: 'INGRESO', type: 'D', size: 8 },
{ name: 'ACTIVO', type: 'L', size: 1 },
], { encoding: 'cp1252' });
await dbf.appendRecords([
{ CEDULA: '0102030405', NOMBRE: 'MUÑOZ PEÑA JOSÉ', CARGO: 'Contador', SUELDO: 1200.5, INGRESO: new Date('2020-03-15'), ACTIVO: true },
{ CEDULA: '0911122233', NOMBRE: 'ANDRADE LUISA', CARGO: 'Recepción', SUELDO: 600, INGRESO: new Date('2023-01-02'), ACTIVO: true },
{ CEDULA: '1700000001', NOMBRE: 'BORRADO TEST', CARGO: 'X', SUELDO: 1, INGRESO: null, ACTIVO: false },
]);
// Marcar el 3.er registro como borrado (byte '*' al inicio del registro), como hace FoxPro con DELETE.
const buf = fs.readFileSync(path.join(data, 'EMPLEADO.DBF'));
const headerLen = buf.readUInt16LE(8);
const recLen = buf.readUInt16LE(10);
buf[headerLen + recLen * 2] = 0x2a;
fs.writeFileSync(path.join(data, 'EMPLEADO.DBF'), buf);
fs.writeFileSync(path.join(data, 'OCULTA.DBF'), buf);
const cfgPath = path.join(dir, 'config.json');
fs.writeFileSync(cfgPath, JSON.stringify({
host: '127.0.0.1',
port: PORT,
tokenSha256: crypto.createHash('sha256').update(TOKEN).digest('hex'),
allowIps: ['127.0.0.1'],
sources: [{ id: 'nomina', path: data, encoding: 'cp1252', tables: { EMPLEADO: {} }, denyFields: ['SUELDO'] }],
logDir: path.join(dir, 'logs'),
}));
const srv = spawn(process.execPath, [path.join(__dirname, '..', 'src', 'server.js')], { env: { ...process.env, CONECTOR_CONFIG: cfgPath }, stdio: ['ignore', 'pipe', 'inherit'] });
await new Promise((resolve, reject) => {
srv.stdout.once('data', resolve);
srv.once('exit', (c) => reject(new Error(`el servidor salió con ${c}`)));
});
const get = async (p, token = TOKEN, method = 'GET') => {
const r = await fetch(`http://127.0.0.1:${PORT}${p}`, { method, headers: token ? { Authorization: `Bearer ${token}` } : {} });
return { status: r.status, body: await r.json() };
};
let ok = 0;
const check = async (name, fn) => {
await fn();
ok += 1;
console.log(`OK ${name}`);
};
try {
await check('sin token → 401', async () => assert.strictEqual((await get('/v1/health', null)).status, 401));
await check('token errado → 401', async () => assert.strictEqual((await get('/v1/health', 'otro')).status, 401));
await check('POST → 405', async () => assert.strictEqual((await get('/v1/health', TOKEN, 'POST')).status, 405));
await check('health', async () => assert.strictEqual((await get('/v1/health')).body.contract, 'v1'));
await check('sources', async () => assert.deepStrictEqual((await get('/v1/sources')).body.sources.map((s) => s.id), ['nomina']));
await check('tables solo permitidas', async () => {
const t = (await get('/v1/sources/nomina/tables')).body.tables;
assert.deepStrictEqual(t.map((x) => x.name), ['EMPLEADO']);
});
await check('tabla no permitida → 404', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/OCULTA/schema')).status, 404));
await check('traversal → 400', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/..%2F..%2Fx/rows')).status, 400));
await check('schema sin campo denegado', async () => {
const s = (await get('/v1/sources/nomina/tables/empleado/schema')).body;
assert.deepStrictEqual(s.fields.map((f) => f.name), ['CEDULA', 'NOMBRE', 'CARGO', 'INGRESO', 'ACTIVO']);
});
await check('rows: tildes, fecha, sin borrados', async () => {
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows')).body;
assert.strictEqual(r.total, 2);
assert.strictEqual(r.rows[0].NOMBRE, 'MUÑOZ PEÑA JOSÉ');
assert.strictEqual(r.rows[0].INGRESO, '2020-03-15');
assert.strictEqual(r.rows[0].SUELDO, undefined);
});
await check('rows includeDeleted', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/EMPLEADO/rows?includeDeleted=1')).body.total, 3));
await check('búsqueda sin tildes', async () => {
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?q=pena%20jose&qFields=NOMBRE')).body;
assert.strictEqual(r.total, 1);
});
await check('eq + fields', async () => {
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?eq.CEDULA=0911122233&fields=CEDULA,CARGO')).body;
assert.deepStrictEqual(r.rows, [{ _recno: 2, CEDULA: '0911122233', CARGO: 'Recepción' }]);
});
await check('pedir campo denegado → 400', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/EMPLEADO/rows?fields=SUELDO')).status, 400));
await check('paginación', async () => {
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?limit=1&offset=1')).body;
assert.strictEqual(r.rows.length, 1);
assert.strictEqual(r.rows[0]._recno, 2);
});
await check('versión cambia al modificar la tabla', async () => {
const v1 = (await get('/v1/sources/nomina/tables/EMPLEADO/schema')).body.version;
const f = await DBFFile.open(path.join(data, 'EMPLEADO.DBF'), { encoding: 'cp1252' });
await f.appendRecords([{ CEDULA: '0000000001', NOMBRE: 'NUEVO', CARGO: 'Y', SUELDO: 2, INGRESO: null, ACTIVO: true }]);
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows')).body;
assert.notStrictEqual(r.version, v1);
assert.strictEqual(r.total, 3);
});
console.log(`\n${ok} pruebas OK`);
} finally {
srv.kill();
fs.rmSync(dir, { recursive: true, force: true });
}
}
main().catch((err) => {
console.error('FALLÓ:', err);
process.exit(1);
});