feature/mejora-del-side-bar (#14)
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
mejora de sidebar y correcciones en interfaces y propagacion de contrasena sql Reviewed-on: #14 Co-authored-by: hreyes <hreyes@aduanasoft.com.mx> Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
@@ -378,17 +378,19 @@ export async function insertDatabaseNode(row: {
|
||||
activo: number;
|
||||
restoreTargetId?: number | null;
|
||||
}): Promise<void> {
|
||||
// server_name se deriva del servidor de restauración asignado (su IP); si el target
|
||||
// aún no tiene IP configurada, se conserva el serverName recibido (puede ir vacío).
|
||||
// server_name y sql_password se derivan del servidor de restauración asignado (su IP y su
|
||||
// credencial SQL cifrada); si el target aún no tiene IP, se conserva el serverName recibido.
|
||||
await pgPool.query(
|
||||
`
|
||||
INSERT INTO ${qNodes()} (
|
||||
node_subnode_key, rfc, legal_name, branch_name,
|
||||
notification_email, server_name, database_name, is_active, restore_target_id
|
||||
notification_email, server_name, database_name, is_active, restore_target_id,
|
||||
sql_password
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $9), $6),
|
||||
$7, $8, $9
|
||||
$7, $8, $9,
|
||||
(SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $9)
|
||||
)
|
||||
`,
|
||||
[
|
||||
@@ -430,7 +432,8 @@ export async function updateDatabaseNode(
|
||||
server_name = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $9), $6),
|
||||
database_name = $7,
|
||||
is_active = $8,
|
||||
restore_target_id = $9
|
||||
restore_target_id = $9,
|
||||
sql_password = (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $9)
|
||||
WHERE id = $10
|
||||
`,
|
||||
[
|
||||
@@ -829,9 +832,10 @@ export async function listNodesForAssignment(): Promise<AssignmentNode[]> {
|
||||
|
||||
/**
|
||||
* Guarda el checklist manual de un restaurador: los `checkedNodeIds` quedan asignados a
|
||||
* `targetId` (reasignando desde donde estuvieran y derivando server_name de su IP); los nodos
|
||||
* que estaban en este restaurador y ya NO vienen marcados quedan sin asignar (NULL). Los nodos
|
||||
* de OTROS restauradores no marcados no se tocan. Transacción con prepared statements.
|
||||
* `targetId` (reasignando desde donde estuvieran y derivando server_name y sql_password del
|
||||
* target); los nodos que estaban en este restaurador y ya NO vienen marcados quedan sin asignar
|
||||
* (restore_target_id y sql_password en NULL). Los nodos de OTROS restauradores no marcados no se
|
||||
* tocan. Transacción con prepared statements.
|
||||
*/
|
||||
export async function assignNodesToRestoreTarget(
|
||||
targetId: number,
|
||||
@@ -841,21 +845,23 @@ export async function assignNodesToRestoreTarget(
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
// 1) Asignar/reasignar los marcados a este restaurador (server_name = IP del target si existe).
|
||||
// 1) Asignar/reasignar los marcados (server_name = IP del target; sql_password = su credencial cifrada).
|
||||
await client.query(
|
||||
`
|
||||
UPDATE ${qNodes()}
|
||||
SET restore_target_id = $1,
|
||||
server_name = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $1), server_name)
|
||||
server_name = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $1), server_name),
|
||||
sql_password = (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $1)
|
||||
WHERE id = ANY($2::int[])
|
||||
`,
|
||||
[targetId, ids]
|
||||
);
|
||||
// 2) Quitar de este restaurador los que quedaron desmarcados (NULL); no toca otros targets.
|
||||
// 2) Quitar de este restaurador los desmarcados (restore_target_id y sql_password a NULL); no toca otros targets.
|
||||
await client.query(
|
||||
`
|
||||
UPDATE ${qNodes()}
|
||||
SET restore_target_id = NULL
|
||||
SET restore_target_id = NULL,
|
||||
sql_password = NULL
|
||||
WHERE restore_target_id = $1
|
||||
AND NOT (id = ANY($2::int[]))
|
||||
`,
|
||||
@@ -872,8 +878,9 @@ export async function assignNodesToRestoreTarget(
|
||||
|
||||
/**
|
||||
* Aplica un conjunto de asignaciones (distribución global o automática): cada par fija el
|
||||
* restore_target_id del nodo (y deriva server_name de la IP del restaurador si no es NULL).
|
||||
* Pares con targetId NULL dejan el nodo sin asignar (conservando server_name). Transacción.
|
||||
* restore_target_id del nodo (y deriva server_name y sql_password del restaurador si no es NULL).
|
||||
* Pares con targetId NULL dejan el nodo sin asignar (conservando server_name, sql_password a NULL).
|
||||
* Transacción.
|
||||
*/
|
||||
export async function applyNodeAssignments(
|
||||
pairs: { nodeId: number; targetId: number | null }[]
|
||||
@@ -891,6 +898,10 @@ export async function applyNodeAssignments(
|
||||
server_name = CASE
|
||||
WHEN $2::int IS NULL THEN server_name
|
||||
ELSE COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $2), server_name)
|
||||
END,
|
||||
sql_password = CASE
|
||||
WHEN $2::int IS NULL THEN NULL
|
||||
ELSE (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $2)
|
||||
END
|
||||
WHERE id = $1
|
||||
`,
|
||||
@@ -1234,6 +1245,57 @@ export async function listFailedRestoreJobLogs(limit = 100): Promise<FailedResto
|
||||
}
|
||||
}
|
||||
|
||||
/** Restores completados/reenviados (todos los servidores) para el panel de restaurados. */
|
||||
export interface RestoredRestoreRow {
|
||||
id: number;
|
||||
restore_target_id: number | null;
|
||||
server_name: string | null;
|
||||
node_key: string | null;
|
||||
client_name: string | null;
|
||||
db_name: string | null;
|
||||
filename: string;
|
||||
rel_path: string | null;
|
||||
size_bytes: number | null;
|
||||
restored_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores exitosos (`completed`/`forwarded`) desde restore_job_logs — lo que reporta
|
||||
* CloudRestoreAS vía POST /api/restore/job-result. Resuelve node_key/client_name por nombre
|
||||
* de base contra database_nodes (LATERAL … LIMIT 1 para no duplicar la fila del log si dos
|
||||
* nodos comparten database_name). Cuando no hay match, ambos quedan null y la UI cae a db_name.
|
||||
*/
|
||||
export async function listRestoredRestoreJobLogs(limit = 200): Promise<RestoredRestoreRow[]> {
|
||||
const capped = Math.min(Math.max(1, Math.trunc(limit)), 500);
|
||||
try {
|
||||
await ensureRestoreJobLogColumns();
|
||||
const r = await pgPool.query(
|
||||
`
|
||||
SELECT
|
||||
jl.id, jl.restore_target_id, rt.name AS server_name,
|
||||
dn.node_subnode_key AS node_key, dn.legal_name AS client_name,
|
||||
jl.filename, jl.db_name, jl.rel_path, jl.size_bytes, jl.restored_at
|
||||
FROM ${qRestoreJobLogs()} jl
|
||||
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = jl.restore_target_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT n.node_subnode_key, n.legal_name
|
||||
FROM ${qNodes()} n
|
||||
WHERE LOWER(TRIM(n.database_name)) = LOWER(TRIM(jl.db_name))
|
||||
LIMIT 1
|
||||
) dn ON true
|
||||
WHERE jl.status IN ('completed', 'forwarded')
|
||||
ORDER BY jl.restored_at DESC
|
||||
LIMIT $1
|
||||
`,
|
||||
[capped]
|
||||
);
|
||||
return r.rows as RestoredRestoreRow[];
|
||||
} catch (e) {
|
||||
if (isPgUndefinedTable(e)) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Carpetas de un restaurador para descarga por filesystem (input/processed reportados). */
|
||||
export interface RestoreTargetDownload {
|
||||
id: number;
|
||||
|
||||
54
src/lib/server/mssql-nodes.test.ts
Normal file
54
src/lib/server/mssql-nodes.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Pruebas de resolveNodeSqlPassword: la columna database_nodes.sql_password se autorrellena
|
||||
* al asignar un restaurador copiando su sobre cifrado (gcm:), así que al resolverla hay que
|
||||
* descifrarla. Cubre: sin valor → global, texto plano legado, sobre gcm: descifrado, y
|
||||
* descifrado que falla → global (no debe tumbar el dashboard).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
// Se conserva el isEncrypted real (trivial: prefijo gcm:) y solo se controla el descifrado.
|
||||
vi.mock('./crypto', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./crypto')>();
|
||||
return { ...actual, decryptSecret: vi.fn() };
|
||||
});
|
||||
|
||||
import { decryptSecret } from './crypto';
|
||||
import { resolveNodeSqlPassword } from './mssql-nodes';
|
||||
|
||||
const decryptMock = vi.mocked(decryptSecret);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
env.PANEL_MSSQL_PASSWORD = 'GLOBAL_PWD';
|
||||
});
|
||||
|
||||
describe('resolveNodeSqlPassword', () => {
|
||||
it('sin valor (null/undefined/vacío/espacios) usa la contraseña global', () => {
|
||||
expect(resolveNodeSqlPassword(null)).toBe('GLOBAL_PWD');
|
||||
expect(resolveNodeSqlPassword(undefined)).toBe('GLOBAL_PWD');
|
||||
expect(resolveNodeSqlPassword('')).toBe('GLOBAL_PWD');
|
||||
expect(resolveNodeSqlPassword(' ')).toBe('GLOBAL_PWD');
|
||||
expect(decryptMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('texto plano legado (sin prefijo gcm:) se usa tal cual y se recorta, sin descifrar', () => {
|
||||
expect(resolveNodeSqlPassword(' MiPassPlano ')).toBe('MiPassPlano');
|
||||
expect(decryptMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sobre cifrado gcm: se descifra y devuelve el texto plano', () => {
|
||||
decryptMock.mockReturnValue('SecretoDescifrado');
|
||||
const envelope = 'gcm:aXY=:dGFn:Y2lwaGVy';
|
||||
expect(resolveNodeSqlPassword(envelope)).toBe('SecretoDescifrado');
|
||||
expect(decryptMock).toHaveBeenCalledWith(envelope);
|
||||
});
|
||||
|
||||
it('si el descifrado falla (sobre corrupto o clave equivocada) cae a la global sin propagar el error', () => {
|
||||
decryptMock.mockImplementation(() => {
|
||||
throw new Error('autenticación GCM inválida');
|
||||
});
|
||||
expect(resolveNodeSqlPassword('gcm:corrupto')).toBe('GLOBAL_PWD');
|
||||
expect(decryptMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,13 @@
|
||||
*/
|
||||
import sql from 'mssql';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { decryptSecret, isEncrypted } from './crypto';
|
||||
|
||||
// Timeout de conexión acotado en TODOS los entornos: un SQL Server sano conecta en <1s,
|
||||
// así que 5s (alineado con el pool de PostgreSQL) es holgado. Antes eran 30s, lo que colgaba
|
||||
// la carga del dashboard cuando algún servidor no responde. Configurable por env si un
|
||||
// despliegue lo necesita.
|
||||
const MSSQL_CONNECT_TIMEOUT_MS = Number(env.PANEL_MSSQL_CONNECT_TIMEOUT_MS) || 5000;
|
||||
|
||||
const MAX_POOLS = 16;
|
||||
// Se cachea la *promesa* del pool (no el pool ya resuelto) para que varias cargas de nodos
|
||||
@@ -35,10 +42,22 @@ export function resolveMssqlUser(): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** Contraseña SQL: columna sql_password del nodo (texto plano) o variable global. */
|
||||
/**
|
||||
* Contraseña SQL del nodo. La columna sql_password se autorrellena al asignar un
|
||||
* restaurador copiando su sobre cifrado (gcm:), así que aquí se descifra al vuelo.
|
||||
* Acepta también texto plano legado. Si no hay valor por nodo, cae a la variable global.
|
||||
*/
|
||||
export function resolveNodeSqlPassword(nodeSqlPassword: string | null | undefined): string {
|
||||
const raw = nodeSqlPassword != null ? String(nodeSqlPassword).trim() : '';
|
||||
if (raw) return raw;
|
||||
if (raw) {
|
||||
if (!isEncrypted(raw)) return raw; // texto plano legado
|
||||
try {
|
||||
return decryptSecret(raw);
|
||||
} catch {
|
||||
// sobre corrupto o clave equivocada: caer al global en vez de tumbar el dashboard
|
||||
return String(env.PANEL_MSSQL_PASSWORD || '').trim();
|
||||
}
|
||||
}
|
||||
return String(
|
||||
env.PANEL_MSSQL_PASSWORD || ''
|
||||
).trim();
|
||||
@@ -113,10 +132,13 @@ export async function getMssqlPoolMaster(serverHost: string, password: string):
|
||||
password,
|
||||
server,
|
||||
database: 'master',
|
||||
// node-mssql gobierna el timeout de conexión con `connectionTimeout` (top-level);
|
||||
// se replica en options.connectTimeout (tedious) para cubrir ambas rutas.
|
||||
connectionTimeout: MSSQL_CONNECT_TIMEOUT_MS,
|
||||
options: {
|
||||
encrypt: true,
|
||||
trustServerCertificate: true,
|
||||
connectTimeout: 30000
|
||||
connectTimeout: MSSQL_CONNECT_TIMEOUT_MS
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user