/** * Instalador remoto de CloudRestoreAS: utilidades de ejecución y quoting. * * La prueba más importante de este archivo es la de fuga de secretos: ni el token del panel ni * la contraseña SSH deben aparecer NUNCA en un comando remoto, porque `ps` y el historial del * servidor destino son legibles por otros usuarios. Es exactamente el defecto del instalador de * AServers, que hace `echo '{password}' | sudo -S ...`. * * Nótese qué prohíbe ese invariante y qué no: prohíbe el secreto en la LÍNEA DE COMANDOS, no * elevar con contraseña. `sudo -S` alimentado por el stdin del canal cumple la regla, y es lo * que permite instalar en /opt sin ir a tocar el sudoers del servidor. */ import { describe, expect, it, vi } from 'vitest'; // La verificación del despliegue asienta cada paso en cras_install_runs, que va a PostgreSQL. // Aquí interesa la DECISIÓN (¿esto cuenta como desplegado?), no la bitácora, así que se // sustituye el módulo entero en vez de levantar una base. vi.mock('./cras-releases', () => ({ appendInstallStep: vi.fn(async () => {}), finishInstallRun: vi.fn(async () => {}), getCrasReleaseById: vi.fn(async () => null), listCrasTargetInventory: vi.fn(async () => []), startInstallRun: vi.fn(async () => 1), InstallRunConflictError: class extends Error {} })); import { execRemote, probeInPlaceUpdate, probeLinuxElevation, alignWindowsTask, normalizeWindowsPath, probeWindowsAgentProcess, probeWindowsElevation, probeWindowsTask, sameWindowsPath, psEncoded, shQuote, verifyWindowsDeployment, InstallError } from './cras-install'; /** Cliente SFTP falso que expone un `client.exec` controlable, como el real. */ function fakeSftp(handler: (command: string) => { code?: number; stdout?: string; stderr?: string }) { const commands: string[] = []; /** Lo que se escribió en el stdin de cada comando, en el mismo orden que `commands`. */ const stdins: (string | null)[] = []; const sftp = { client: { exec(command: string, callback: (err: Error | null, stream?: unknown) => void) { commands.push(command); const index = stdins.push(null) - 1; const result = handler(command); const listeners: Record void)[]> = {}; const stderrListeners: ((...args: unknown[]) => void)[] = []; let ended = false; const stream = { on(event: string, fn: (...args: unknown[]) => void) { (listeners[event] ??= []).push(fn); return stream; }, write(chunk: string) { stdins[index] = (stdins[index] ?? '') + chunk; return true; }, end() { ended = true; }, get stdinClosed() { return ended; }, stderr: { on(_event: string, fn: (...args: unknown[]) => void) { stderrListeners.push(fn); return stream.stderr; } } }; callback(null, stream); // Se emite en el mismo orden que ssh2: data → exit → close. setImmediate(() => { if (result.stdout) { for (const fn of listeners.data ?? []) fn(Buffer.from(result.stdout)); } if (result.stderr) { for (const fn of stderrListeners) fn(Buffer.from(result.stderr)); } for (const fn of listeners.exit ?? []) fn(result.code ?? 0); for (const fn of listeners.close ?? []) fn(); }); } } }; return { sftp, commands, stdins }; } describe('shQuote', () => { it('envuelve en comillas simples', () => { expect(shQuote('/tmp/cras-1.1.0')).toBe("'/tmp/cras-1.1.0'"); }); it('escapa comillas simples internas', () => { // Sin esto, un nombre con apóstrofo cerraría la comilla y el resto se ejecutaría. expect(shQuote("a'b")).toBe("'a'\\''b'"); }); it('neutraliza metacaracteres de shell', () => { const quoted = shQuote('x; rm -rf /'); expect(quoted).toBe("'x; rm -rf /'"); // El ; queda dentro de las comillas, así que no separa comandos. expect(quoted.startsWith("'")).toBe(true); expect(quoted.endsWith("'")).toBe(true); }); }); describe('psEncoded', () => { it('codifica en base64 UTF-16LE, como espera -EncodedCommand', () => { const command = psEncoded('Write-Output "hola"'); expect(command).toContain('-EncodedCommand'); const base64 = command.split('-EncodedCommand ')[1]; expect(Buffer.from(base64, 'base64').toString('utf16le')).toBe('Write-Output "hola"'); }); it('usa -NoProfile y -NonInteractive para que no cuelgue esperando entrada', () => { const command = psEncoded('$x = 1'); expect(command).toContain('-NoProfile'); expect(command).toContain('-NonInteractive'); expect(command).toContain('-ExecutionPolicy Bypass'); }); it('sobrevive a comillas y rutas de Windows sin escapes manuales', () => { // Es la razón de usar -EncodedCommand: el script viaja intacto por cmd.exe. const script = `& 'C:\\Aduanasoft\\CloudRestoreAS\\install.ps1' -Service`; const base64 = psEncoded(script).split('-EncodedCommand ')[1]; expect(Buffer.from(base64, 'base64').toString('utf16le')).toBe(script); }); }); describe('execRemote', () => { it('devuelve código, stdout y stderr recortados', async () => { const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'Linux\n', stderr: ' aviso ' })); const result = await execRemote(sftp as never, 'uname -s'); expect(result).toEqual({ code: 0, stdout: 'Linux', stderr: 'aviso' }); }); it('propaga un código de salida distinto de cero sin lanzar', async () => { // El llamador decide qué hacer con cada código; lanzar aquí perdería el stderr. const { sftp } = fakeSftp(() => ({ code: 2, stderr: 'no existe' })); const result = await execRemote(sftp as never, 'command -v tar'); expect(result.code).toBe(2); expect(result.stderr).toBe('no existe'); }); it('rechaza si la conexión no expone exec()', async () => { await expect(execRemote({} as never, 'ls')).rejects.toThrow(InstallError); }); it('rechaza con 504 al vencer el timeout', async () => { const sftp = { client: { exec(_command: string, callback: (err: Error | null, stream?: unknown) => void) { // Nunca emite close: simula un comando colgado en el destino. callback(null, { on() { return this; }, stderr: { on() { return this; } } }); } } }; await expect(execRemote(sftp as never, 'sleep 999', 30)).rejects.toMatchObject({ status: 504 }); }); it('propaga el error de exec como InstallError', async () => { const sftp = { client: { exec(_command: string, callback: (err: Error | null) => void) { callback(new Error('canal rechazado')); } } }; await expect(execRemote(sftp as never, 'ls')).rejects.toThrow(/canal rechazado/); }); }); describe('no fuga de secretos en los comandos remotos', () => { const TOKEN = 'tok-super-secreto-abc123'; it('el flujo de comandos nunca contiene el token del panel', async () => { // Se simula la secuencia de comandos que arma installLinux y se verifica que ninguno // incluya el token: el token debe viajar SOLO en el archivo subido por SFTP. const remoteDir = '/tmp/cras-1.1.0-7'; const remoteEnv = `${remoteDir}/panel.env`; const comandos = [ 'uname -s', 'id -u', 'sudo -n true', 'command -v tar', `mkdir -p ${shQuote(remoteDir)} && chmod 700 ${shQuote(remoteDir)}`, `sha256sum ${shQuote(`${remoteDir}/CloudRestoreAS-1.1.0-linux-x86_64.tar.gz`)}`, `tar xzf ${shQuote(`${remoteDir}/pkg.tar.gz`)} -C ${shQuote(remoteDir)}`, `cd ${shQuote(`${remoteDir}/CloudRestoreAS`)} && sudo -n ./install.sh --service --panel-env-file ${shQuote(remoteEnv)}`, `cat ${shQuote(`/opt/cloudrestoreas/config/.version`)} 2>/dev/null`, `rm -rf ${shQuote(remoteDir)}` ]; for (const comando of comandos) { expect(comando, comando).not.toContain(TOKEN); } // El instalador solo recibe la RUTA del archivo con el token, no su contenido. const installCmd = comandos.find((c) => c.includes('install.sh')); expect(installCmd).toContain('--panel-env-file'); expect(installCmd).toContain(remoteEnv); }); it('sudo se invoca con -n y jamás recibe una contraseña por stdin', async () => { const { sftp, commands } = fakeSftp(() => ({ code: 0, stdout: '' })); await execRemote(sftp as never, 'sudo -n true'); await execRemote(sftp as never, 'sudo -n systemctl is-active cloudrestoreas'); for (const command of commands) { expect(command).toContain('-n'); // El antipatrón de AServers: echo '' | sudo -S ... expect(command).not.toMatch(/\|\s*sudo\s+-S/); expect(command).not.toMatch(/echo\s+['"].*['"]\s*\|/); } }); it('el script de PowerShell del instalador tampoco lleva el token', () => { const remoteEnv = 'C:\\Users\\svc\\AppData\\Local\\Temp\\cras\\panel.env'; const command = psEncoded( `& 'C:\\x\\install.ps1' -Service -PanelEnvFile '${remoteEnv}'; exit $LASTEXITCODE` ); const decoded = Buffer.from(command.split('-EncodedCommand ')[1], 'base64').toString( 'utf16le' ); expect(decoded).not.toContain(TOKEN); expect(decoded).toContain('-PanelEnvFile'); }); }); /** * Sonda de elevación en Linux. No tenía ninguna prueba, y es la que decide si una instalación * se aborta antes de transferir 270 MB — y ahora también si puede seguir sin privilegios. * * Lo que se cubre son los diagnósticos que estaban MAL: `requiretty` se reportaba como "sin * privilegios", y el remedio que se ofrecía (configurar NOPASSWD) no arregla ese caso; y una * regla NOPASSWD acotada a otros comandos se veía idéntica a no tener ninguna. */ describe('probeLinuxElevation', () => { /** Responde por comando, con lo que devolvería el destino real. */ function sftpFor(responses: Record) { return fakeSftp((command) => { for (const [needle, result] of Object.entries(responses)) { if (command.includes(needle)) return result; } return { code: 1, stdout: '', stderr: '' }; }); } it('root: sin prefijo de elevación', async () => { const { sftp } = sftpFor({ 'id -u': { code: 0, stdout: '0' } }); const r = await probeLinuxElevation(sftp as never); expect(r).toMatchObject({ elevation: 'root', prefix: '', label: 'root' }); }); it('sudo sin password: prefija con -n, nunca con -S', async () => { const { sftp } = sftpFor({ 'id -u': { code: 0, stdout: '1000' }, 'sudo -n true': { code: 0 } }); const r = await probeLinuxElevation(sftp as never); expect(r.elevation).toBe('sudo-sin-password'); expect(r.prefix).toContain('-n'); expect(r.prefix).not.toContain('-S'); }); it('requiretty se distingue de "sin privilegios": el remedio es el opuesto', async () => { const { sftp } = sftpFor({ 'id -u': { code: 0, stdout: '1000' }, 'sudo -n true': { code: 1, stderr: 'sudo: sorry, you must have a tty to run sudo' } }); const r = await probeLinuxElevation(sftp as never); expect(r.elevation).toBe('requiretty'); // Lo importante del mensaje: que NO mande a configurar NOPASSWD, que no arregla esto. expect(r.detail).toMatch(/requiretty/); expect(r.detail).toMatch(/NO lo arregla/); }); it('detecta reglas NOPASSWD acotadas a otros comandos', async () => { const { sftp } = sftpFor({ 'id -u': { code: 0, stdout: '1000' }, 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, 'sudo -n -l': { code: 0, stdout: 'User srvmid_db may run:\n (root) NOPASSWD: /usr/bin/systemctl' } }); const r = await probeLinuxElevation(sftp as never); expect(r.elevation).toBe('ninguna'); expect(r.detail).toMatch(/acotadas/); expect(r.detail).toContain('systemctl'); }); it('sin privilegios de ninguna clase, y sin prefijo que pueda elevar', async () => { const { sftp } = sftpFor({ 'id -u': { code: 0, stdout: '1000' }, 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, 'sudo -n -l': { code: 1, stdout: '' } }); const r = await probeLinuxElevation(sftp as never); expect(r.elevation).toBe('ninguna'); expect(r.prefix).toBe(''); }); it('sudo con la contraseña del panel: eleva cuando `sudo -n` no puede', async () => { // El caso que dejaba tirado al operador: la cuenta SÍ está en el sudoers, solo que con // contraseña. El panel ya la tiene —es con la que abrió la sesión— y ahora la usa. const { sftp } = sftpFor({ 'id -u': { code: 0, stdout: '1000' }, 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, "sudo -S -k -p '' true": { code: 0 } }); const r = await probeLinuxElevation(sftp as never, 'secreto-del-panel'); expect(r.elevation).toBe('sudo-con-password'); expect(r.prefix).toContain('-S'); // El prefijo y su stdin son inseparables: sin esto sudo se queda esperando. expect(r.stdin).toBe('secreto-del-panel\n'); }); it('la contraseña viaja por stdin y NUNCA por la línea de comandos', async () => { // El invariante que hereda del defecto de AServers: `echo '{pw}' | sudo -S` deja la // contraseña en el argv del `sh -c`, legible con `ps` por cualquier usuario del destino. // Lo que se prohíbe es eso, no elevar con contraseña. const PASSWORD = 'clave-que-no-debe-filtrarse'; const { sftp, commands, stdins } = sftpFor({ 'id -u': { code: 0, stdout: '1000' }, 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, "sudo -S -k -p '' true": { code: 0 } }); const r = await probeLinuxElevation(sftp as never, PASSWORD); expect(commands.some((c) => c.includes(PASSWORD))).toBe(false); expect(commands.some((c) => c.includes('echo'))).toBe(false); expect(r.prefix).not.toContain(PASSWORD); // Y sí llegó por el único canal admitido. expect(stdins.some((s) => s?.includes(PASSWORD))).toBe(true); }); it('requiretty NO gasta un intento de contraseña: sudo rechaza antes de mirar la política', async () => { // Mandarle la contraseña ahí no arreglaría nada y sumaría un fallo al log de auth del // destino, que es justo lo que dispara las alertas de fuerza bruta. const { sftp, commands } = sftpFor({ 'id -u': { code: 0, stdout: '1000' }, 'sudo -n true': { code: 1, stderr: 'sudo: sorry, you must have a tty to run sudo' } }); const r = await probeLinuxElevation(sftp as never, 'secreto-del-panel'); expect(r.elevation).toBe('requiretty'); expect(commands.some((c) => c.includes('-S'))).toBe(false); }); it('contraseña rechazada: lo dice, en vez de culpar a la falta de sudo', async () => { // Los dos remedios son opuestos: meter al usuario en el sudoers, o corregir la // contraseña registrada en el panel. Un mensaje genérico manda a hacer lo que no es. const { sftp } = sftpFor({ 'id -u': { code: 0, stdout: '1000' }, 'sudo -n true': { code: 1, stderr: 'sudo: a password is required' }, "sudo -S -k -p '' true": { code: 1, stderr: 'sudo: 1 incorrect password attempt' }, 'sudo -n -l': { code: 1, stdout: '' } }); const r = await probeLinuxElevation(sftp as never, 'clave-vieja'); expect(r.elevation).toBe('ninguna'); expect(r.prefix).toBe(''); expect(r.detail).toMatch(/contraseña SSH guardada en el panel/); }); }); /** * Sonda de elevación en Windows. * * El estado que se añadió —y el motivo de estas pruebas— es "administrador con token filtrado": * una cuenta que SÍ está en Administradores pero a la que OpenSSH entregó el token filtrado por * UAC. Se veía igual que "no eres administrador", y el remedio es el opuesto: ahí la cuenta ya * es la correcta y lo que falta es una política del servidor. */ describe('probeWindowsElevation', () => { /** El comando va base64/UTF-16LE dentro de -EncodedCommand: hay que decodificarlo. */ function fakePsSftp(reply: string) { return fakeSftp((command) => { const encoded = command.split('-EncodedCommand ')[1] ?? ''; const script = Buffer.from(encoded, 'base64').toString('utf16le'); // Se comprueba que la sonda mire el SID del grupo, que es lo único que distingue el // token filtrado de una cuenta sin privilegios. if (!script.includes('S-1-5-32-544')) return { code: 1, stdout: '' }; return { code: 0, stdout: reply }; }); } it('administrador con token completo', async () => { const { sftp } = fakePsSftp('admin'); const r = await probeWindowsElevation(sftp as never); expect(r.elevation).toBe('admin'); expect(r.detail).toBe(''); }); it('token filtrado por UAC: NO manda a cambiar de cuenta', async () => { const { sftp } = fakePsSftp('token-filtrado'); const r = await probeWindowsElevation(sftp as never); expect(r.elevation).toBe('token-filtrado'); // El remedio correcto es la política del servidor, no otra cuenta. expect(r.detail).toMatch(/LocalAccountTokenFilterPolicy/); expect(r.detail).toMatch(/No hay que cambiar de cuenta/); }); it('cuenta sin privilegios: ahí sí hace falta otra cuenta', async () => { const { sftp } = fakePsSftp('limitado'); const r = await probeWindowsElevation(sftp as never); expect(r.elevation).toBe('limitado'); expect(r.detail).toMatch(/cuenta administradora/); }); }); /** * Sondas de Windows que miran la RUTA, no el nombre. * * Son la respuesta al fallo silencioso: todo el camino identificaba al agente por NOMBRE, mientras * que lo único que se actualiza se identifica por RUTA. En cuanto las dos no coincidían se * reemplazaba un binario y se arrancaba otro, y ni el instalador ni el panel se enteraban. */ /** * Rutas de instalación personalizadas. * * `C:\Aduanasoft\CloudRestoreAS-win` es el peor caso posible y existe en producción: la ruta por * omisión `C:\Aduanasoft\CloudRestoreAS` es **prefijo de cadena** de ella. Cualquier comparación * hecha con `startsWith` daría por iguales dos instalaciones distintas, y el resultado sería * exactamente el fallo que se estuvo persiguiendo: actualizar una carpeta y arrancar la otra. * * Las pruebas de más abajo usaban nombres sin relación entre sí (`C:\Declarada` contra `C:\Otra`), * así que un `startsWith` mal puesto las pasaba todas. */ describe('comparación de rutas de Windows', () => { const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS'; const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win'; it('NO confunde la ruta por omisión con una que la tiene como prefijo', () => { expect(sameWindowsPath(DEFECTO, PERSONALIZADA)).toBe(false); // Y en el otro sentido: da igual cuál sea la instalada. expect(sameWindowsPath(PERSONALIZADA, DEFECTO)).toBe(false); expect( sameWindowsPath(`${DEFECTO}\\CloudRestoreAS.exe`, `${PERSONALIZADA}\\CloudRestoreAS.exe`) ).toBe(false); }); it('normaliza comillas, barras finales y mayúsculas', () => { // La acción de una tarea guarda la ruta entrecomillada; una ruta capturada a mano suele // traer barra final; y NTFS no distingue caso. expect(sameWindowsPath(`"${PERSONALIZADA}"`, PERSONALIZADA)).toBe(true); expect(sameWindowsPath(`${PERSONALIZADA}\\`, PERSONALIZADA)).toBe(true); expect(sameWindowsPath(PERSONALIZADA.toUpperCase(), PERSONALIZADA)).toBe(true); expect(normalizeWindowsPath(' ""C:\\X\\"" ')).toBe('c:\\x'); }); it('una ruta vacía no es igual a nada, ni a otra vacía', () => { // Si lo fuera, un `execute` sin leer pasaría por "ya está alineada" y no se corregiría nada. expect(sameWindowsPath('', '')).toBe(false); expect(sameWindowsPath('', PERSONALIZADA)).toBe(false); }); }); describe('sondas de ruta en Windows', () => { const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS'; const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win'; function psFake(stdout: string) { return fakeSftp(() => ({ code: 0, stdout })); } it('el proceso de la ruta por omisión no cuenta como el de la personalizada', async () => { const { sftp } = psFake(`corriendo|${DEFECTO}\\CloudRestoreAS.exe`); const r = await probeWindowsAgentProcess(sftp as never, PERSONALIZADA); expect(r.running).toBe(true); expect(r.fromPrefix).toBe(false); }); it('ni al contrario: el de la personalizada no cuenta como el de la de omisión', async () => { const { sftp } = psFake(`corriendo|${PERSONALIZADA}\\CloudRestoreAS.exe`); expect((await probeWindowsAgentProcess(sftp as never, DEFECTO)).fromPrefix).toBe(false); }); it('y el de la personalizada sí cuenta cuando esa es la instalación', async () => { const { sftp } = psFake(`corriendo|${PERSONALIZADA}\\CloudRestoreAS.exe`); expect((await probeWindowsAgentProcess(sftp as never, PERSONALIZADA)).fromPrefix).toBe(true); }); it('probeWindowsTask saca la ruta y la cuenta de la tarea', async () => { const { sftp } = psFake('si|C:\\Otra\\CloudRestoreAS.exe|SYSTEM'); expect(await probeWindowsTask(sftp as never)).toEqual({ exists: true, execute: 'C:\\Otra\\CloudRestoreAS.exe', principal: 'SYSTEM' }); }); it('probeWindowsTask sin tarea registrada', async () => { const { sftp } = psFake('no||'); const r = await probeWindowsTask(sftp as never); expect(r.exists).toBe(false); expect(r.execute).toBe(''); }); it('probeWindowsAgentProcess distingue la instalación propia de otra carpeta', async () => { const { sftp } = psFake('corriendo|C:\\Otra\\CloudRestoreAS.exe'); const r = await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada'); expect(r.running).toBe(true); expect(r.fromPrefix).toBe(false); expect(r.paths).toEqual(['C:\\Otra\\CloudRestoreAS.exe']); }); it('probeWindowsAgentProcess reconoce la propia sin importar mayúsculas', async () => { // NTFS no distingue caso: comparar sensible reportaría "corre desde otra carpeta" sobre la // MISMA ruta y haría fallar una actualización correcta. const { sftp } = psFake('corriendo|c:\\declarada\\cloudrestoreas.exe'); expect((await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada')).fromPrefix).toBe(true); }); it('vivo con la ruta ilegible no se afirma que sea ajeno', async () => { // Un proceso de SYSTEM no expone .Path a una cuenta sin elevación. `paths` vacío significa // "no sé desde dónde corre", y el llamador no debe tratarlo como fallo. const { sftp } = psFake('corriendo|'); const r = await probeWindowsAgentProcess(sftp as never, 'C:\\Declarada'); expect(r.running).toBe(true); expect(r.paths).toEqual([]); expect(r.fromPrefix).toBe(false); }); }); /** * Realineación de la tarea programada DESDE EL PANEL. * * Vive aquí y no solo en install.ps1 porque un instalador ya publicado no se arregla hacia atrás: * el script que se ejecuta en el servidor viaja DENTRO del artefacto, y el de 1.1.3 salió antes de * que install.ps1 aprendiera a realinear la tarea. Sin esto, actualizar a 1.1.3 reemplaza el * binario de una carpeta y `Start-ScheduledTask` levanta el de otra. */ describe('alignWindowsTask', () => { const DESTINO = 'C:\\Aduanasoft\\CloudRestoreAS-win'; const ESPERADO = `${DESTINO}\\CloudRestoreAS.exe`; function psFake(stdout: string) { return fakeSftp(() => ({ code: 0, stdout })); } it('reapunta una tarea que ejecuta otro binario', async () => { const { sftp, commands } = psFake('ok'); const cambio = await alignWindowsTask( sftp as never, 1, 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe', DESTINO ); expect(cambio).toBe(true); const script = Buffer.from( commands[0].split('-EncodedCommand ')[1] ?? '', 'base64' ).toString('utf16le'); expect(script).toContain(ESPERADO); expect(script).toContain('Set-ScheduledTask'); // Se conservan los argumentos: reregistrar desde cero perdería ajustes del operador. expect(script).toContain('Arguments'); // `$args` es una variable automática de PowerShell; asignarla sería un error en ejecución. expect(script).not.toMatch(/\$args\s*=/); }); it('no toca una tarea que ya apunta bien', async () => { const { sftp, commands } = psFake('ok'); expect(await alignWindowsTask(sftp as never, 1, ESPERADO, DESTINO)).toBe(false); expect(commands).toHaveLength(0); }); it('ignora comillas y mayúsculas al comparar', async () => { // La acción de la tarea guarda la ruta entrecomillada y NTFS no distingue caso: comparar en // crudo reescribiría tareas correctas en cada actualización. const { sftp, commands } = psFake('ok'); const cambio = await alignWindowsTask( sftp as never, 1, `"${ESPERADO.toLowerCase()}"`, DESTINO ); expect(cambio).toBe(false); expect(commands).toHaveLength(0); }); it('reapunta cuando la tarea usa la ruta por omisión y la instalación es la personalizada', async () => { // El servidor real: instalado en ...-win, tarea apuntando a la carpeta por omisión. Con una // comparación por prefijo esto se daría por alineado y no se corregiría nada. const { sftp, commands } = psFake('ok'); const cambio = await alignWindowsTask( sftp as never, 1, 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe', 'C:\\Aduanasoft\\CloudRestoreAS-win' ); expect(cambio).toBe(true); expect(commands).toHaveLength(1); }); it('si no se puede corregir, falla nombrando las dos rutas', async () => { const { sftp } = psFake('error|Acceso denegado'); await expect( alignWindowsTask(sftp as never, 1, 'D:\\Vieja\\CloudRestoreAS.exe', DESTINO) ).rejects.toThrow(/D:\\Vieja\\CloudRestoreAS\.exe/); const { sftp: sftp2 } = psFake('error|Acceso denegado'); await expect( alignWindowsTask(sftp2 as never, 1, 'D:\\Vieja\\CloudRestoreAS.exe', DESTINO) ).rejects.toThrow(new RegExp(ESPERADO.replace(/\\/g, '\\\\'))); }); }); /** * Verificación del despliegue en Windows. * * Dos defectos, y el segundo es el que dejaba pasar la actualización que no hacía nada: * * 1. Se daba por buena cualquier tarea con un `State` no vacío. `Ready` es una tarea REGISTRADA QUE * NO ESTÁ CORRIENDO — lo que se ve cuando el agente arrancó y murió a los pocos segundos. * 2. Un sello `config\.version` ausente se toleraba SIEMPRE. Como los agentes anteriores a 1.1.1 * no lo escribían, al actualizar uno de esos no se comprobaba ninguna versión. */ describe('verifyWindowsDeployment', () => { const RELEASE = { version: '1.1.2' } as never; const PREFIJO = 'C:\\CRAS'; /** Responde a cada script de PowerShell según lo que pregunte. */ function fakeWindows(opts: { stamp: string; taskState: string; running: boolean; /** Desde dónde corre. Por omisión, el propio prefijo. */ procPath?: string; /** Ruta que la tarea tiene registrada en su acción. */ taskExecute?: string; crashLog?: string; }) { return fakeSftp((command) => { const encoded = command.split('-EncodedCommand ')[1] ?? ''; const script = Buffer.from(encoded, 'base64').toString('utf16le'); if (script.includes('crash.log')) return { code: 0, stdout: opts.crashLog ?? '' }; if (script.includes('config\\.version')) return { code: 0, stdout: opts.stamp }; if (script.includes('Get-Process')) { if (!opts.running) return { code: 0, stdout: 'detenido' }; const ruta = opts.procPath ?? `${PREFIJO}\\CloudRestoreAS.exe`; return { code: 0, stdout: `corriendo|${ruta}` }; } // La sonda completa de la tarea se distingue de la consulta de estado por que pregunta // también por el principal. if (script.includes('Principal')) { const exec = opts.taskExecute ?? `${PREFIJO}\\CloudRestoreAS.exe`; return { code: 0, stdout: `si|${exec}|SYSTEM` }; } if (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState }; return { code: 1, stdout: '' }; }); } it('tarea Ready con el proceso muerto NO cuenta como desplegado', async () => { const { sftp } = fakeWindows({ stamp: '1.1.2', taskState: 'Ready', running: false }); await expect( verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') ).rejects.toThrow(/el proceso no está corriendo/); }); it('tarea corriendo y proceso vivo sí cuenta', async () => { const { sftp } = fakeWindows({ stamp: '1.1.2', taskState: 'Running', running: true }); await expect( verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') ).resolves.toBeUndefined(); }); it('el proceso se comprueba también fuera del modo servicio', async () => { // Antes se salía antes de mirar nada cuando el arranque no era 'service', así que // actualizar con 'none' dejaba el agente muerto y el run quedaba en verde. const { sftp, commands } = fakeWindows({ stamp: '1.1.2', taskState: '', running: false }); await verifyWindowsDeployment(sftp as never, 1, RELEASE, 'none', 'C:\\CRAS'); const scripts = commands.map((c) => Buffer.from(c.split('-EncodedCommand ')[1] ?? '', 'base64').toString('utf16le') ); expect(scripts.some((s) => s.includes('Get-Process'))).toBe(true); }); it('un sello con otra versión es un fallo, no un aviso', async () => { const { sftp } = fakeWindows({ stamp: '1.1.1', taskState: 'Running', running: true }); await expect( verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', 'C:\\CRAS') ).rejects.toThrow(/reporta la versión 1\.1\.1/); }); it('ACTUALIZANDO, un sello ausente es un fallo', async () => { // El caso real: un agente 1.1.0 no escribía config\.version, así que el sello llegaba vacío, // el vacío se toleraba y el run terminaba en verde sobre un servidor sin actualizar. const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: true }); await expect( verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, true) ).rejects.toThrow(/no escribió config\\\.version/); }); /** * Corre la verificación esperando que falle y devuelve el mensaje. Lanza si NO falla, para que * una comprobación que dejara de detectar el problema no pase como verde con mensaje vacío. */ async function mensajeDeFallo( ...args: Parameters ): Promise { try { await verifyWindowsDeployment(...args); } catch (e) { return e instanceof Error ? e.message : String(e); } throw new Error('se esperaba que verifyWindowsDeployment fallara, y no falló'); } it('el error del sello trae el diagnóstico hecho, no una tarea que investigar', async () => { // El operador lee el mensaje, no la bitácora del run ("no sé dónde verlo"). El panel ya // tiene a mano a qué apunta la tarea, desde dónde corre el proceso y el crash log: mandarlo // a averiguarlo era pedirle algo que aquí ya se sabía. const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: true, taskExecute: 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe', procPath: 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe', crashLog: 'ImportError: no se pudo cargar el driver ODBC' }); const mensaje = await mensajeDeFallo( sftp as never, 1, RELEASE, 'service', PREFIJO, false, true ); expect(mensaje).toContain('C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe'); expect(mensaje).toMatch(/arranque automático ejecuta/); expect(mensaje).toMatch(/hay un proceso corriendo desde/); expect(mensaje).toContain('ImportError'); }); it('sin crash log el mensaje no se rompe', async () => { const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: false }); const mensaje = await mensajeDeFallo( sftp as never, 1, RELEASE, 'service', PREFIJO, false, true ); expect(mensaje).toMatch(/no hay ningún proceso CloudRestoreAS corriendo/); expect(mensaje).not.toContain('Último crash'); }); it('INSTALANDO limpio, un sello ausente sigue siendo aceptable', async () => { // Ahí sí es legítimo: el bootstrap puede no haber alcanzado a escribirlo todavía. const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: true }); await expect( verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, false) ).resolves.toBeUndefined(); }); it('un agente corriendo desde OTRA carpeta no cuenta como desplegado', async () => { // Exactamente el fallo silencioso: se reemplazó el binario de C:\CRAS y el arranque // automático levantó el de otra ruta. Antes pasaba por bueno. const { sftp } = fakeWindows({ stamp: '1.1.2', taskState: 'Running', running: true, procPath: 'D:\\Vieja\\CloudRestoreAS.exe' }); await expect( verifyWindowsDeployment(sftp as never, 1, RELEASE, 'service', PREFIJO, false, true) ).rejects.toThrow(/D:\\Vieja\\CloudRestoreAS\.exe/); }); }); /** * Precondiciones de la actualización en sitio sin privilegios. * * Cada rechazo tiene un remedio distinto, y por eso no basta un booleano. Lo que se prueba aquí * son los NO: el sí es trivial y el daño está en aceptar un servidor donde no se puede. */ describe('probeInPlaceUpdate', () => { const PREFIX = '/opt/cloudrestoreas'; function sftpFor(responses: Record) { return fakeSftp((command) => { for (const [needle, result] of Object.entries(responses)) { if (command.includes(needle)) return result; } return { code: 0, stdout: '' }; }); } /** Destino ideal: ruta del usuario, unit suyo con Restart=always, sin jobs. */ const OK = { 'test -w': { code: 0, stdout: 'si' }, 'systemctl show': { code: 0, stdout: 'User=srvmid_db\nRestart=always' }, '/Temp': { code: 0, stdout: '' } }; it('acepta cuando se cumplen las tres condiciones', async () => { const { sftp } = sftpFor(OK); expect(await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db')).toMatchObject({ ok: true }); }); it('rechaza si el directorio no es escribible, y dice de quién es', async () => { // Sin escritura en el DIRECTORIO no se puede reemplazar el binario, y es el único // permiso que hace falta: `install` desvincula antes de crear. const { sftp } = sftpFor({ ...OK, 'test -w': { code: 1, stdout: 'no' }, 'stat -c': { code: 0, stdout: 'root' } }); const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); expect(r.ok).toBe(false); expect(r.reason).toContain('root'); expect(r.reason).toContain(PREFIX); }); it('rechaza si el servicio corre como otro usuario', async () => { // Si el unit quedó con User=root —alguien instaló desde un `sudo -i`— la cuenta SSH no // puede señalizar el proceso y toda la vía se cae. const { sftp } = sftpFor({ ...OK, 'systemctl show': { code: 0, stdout: 'User=root\nRestart=always' } }); const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); expect(r.ok).toBe(false); expect(r.reason).toMatch(/corre como 'root'/); }); it('trata el User vacío como root, que es lo que hace systemd', async () => { const { sftp } = sftpFor({ ...OK, 'systemctl show': { code: 0, stdout: 'User=\nRestart=always' } }); const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); expect(r.ok).toBe(false); expect(r.reason).toMatch(/root/); }); it('rechaza sin Restart=always: matarlo lo dejaría muerto', async () => { for (const restart of ['on-failure', 'no', '']) { const { sftp } = sftpFor({ ...OK, 'systemctl show': { code: 0, stdout: `User=srvmid_db\nRestart=${restart}` } }); const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); expect(r.ok, `Restart=${restart}`).toBe(false); expect(r.reason).toMatch(/Restart=always/); } }); it('rechaza si hay una restauración en curso, antes de subir 270 MB', async () => { // El mismo chequeo lo repite install.sh en el destino, pero hacerlo aquí evita la // transferencia completa para nada. const { sftp } = sftpFor({ ...OK, '/Temp': { code: 0, stdout: 'job-4212' } }); const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); expect(r.ok).toBe(false); expect(r.reason).toMatch(/restauración en curso/); }); it('el motivo del rechazo nunca queda vacío: cada caso tiene su remedio', async () => { const casos = [ { ...OK, 'test -w': { code: 1, stdout: 'no' } }, { ...OK, 'systemctl show': { code: 0, stdout: 'User=otro\nRestart=always' } }, { ...OK, 'systemctl show': { code: 0, stdout: 'User=srvmid_db\nRestart=no' } }, { ...OK, '/Temp': { code: 0, stdout: 'job-1' } } ]; for (const caso of casos) { const { sftp } = sftpFor(caso); const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); expect(r.ok).toBe(false); expect(r.reason.trim().length).toBeGreaterThan(20); } }); }); /** * Precondiciones de la actualización en sitio sin privilegios. * * Cada rechazo tiene un remedio distinto, y por eso no basta un booleano. Lo que se prueba aquí * son los NO: el sí es trivial y el daño está en aceptar un servidor donde no se puede. */ describe('probeInPlaceUpdate', () => { const PREFIX = '/opt/cloudrestoreas'; function sftpFor(responses: Record) { return fakeSftp((command) => { for (const [needle, result] of Object.entries(responses)) { if (command.includes(needle)) return result; } return { code: 0, stdout: '' }; }); } /** Destino ideal: ruta del usuario, unit suyo con Restart=always, sin jobs. */ const OK = { 'test -w': { code: 0, stdout: 'si' }, 'systemctl show': { code: 0, stdout: 'User=srvmid_db\nRestart=always' }, '/Temp': { code: 0, stdout: '' } }; it('acepta cuando se cumplen las tres condiciones', async () => { const { sftp } = sftpFor(OK); expect(await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db')).toMatchObject({ ok: true }); }); it('rechaza si el directorio no es escribible, y dice de quién es', async () => { // Sin escritura en el DIRECTORIO no se puede reemplazar el binario, y es el único // permiso que hace falta: `install` desvincula antes de crear. const { sftp } = sftpFor({ ...OK, 'test -w': { code: 1, stdout: 'no' }, 'stat -c': { code: 0, stdout: 'root' } }); const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); expect(r.ok).toBe(false); expect(r.reason).toContain('root'); expect(r.reason).toContain(PREFIX); }); it('rechaza si el servicio corre como otro usuario', async () => { // Si el unit quedó con User=root —alguien instaló desde un `sudo -i`— la cuenta SSH no // puede señalizar el proceso y toda la vía se cae. const { sftp } = sftpFor({ ...OK, 'systemctl show': { code: 0, stdout: 'User=root\nRestart=always' } }); const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); expect(r.ok).toBe(false); expect(r.reason).toMatch(/corre como 'root'/); }); it('trata el User vacío como root, que es lo que hace systemd', async () => { const { sftp } = sftpFor({ ...OK, 'systemctl show': { code: 0, stdout: 'User=\nRestart=always' } }); const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); expect(r.ok).toBe(false); expect(r.reason).toMatch(/root/); }); it('rechaza sin Restart=always: matarlo lo dejaría muerto', async () => { for (const restart of ['on-failure', 'no', '']) { const { sftp } = sftpFor({ ...OK, 'systemctl show': { code: 0, stdout: `User=srvmid_db\nRestart=${restart}` } }); const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); expect(r.ok, `Restart=${restart}`).toBe(false); expect(r.reason).toMatch(/Restart=always/); } }); it('rechaza si hay una restauración en curso, antes de subir 270 MB', async () => { // El mismo chequeo lo repite install.sh en el destino, pero hacerlo aquí evita la // transferencia completa para nada. const { sftp } = sftpFor({ ...OK, '/Temp': { code: 0, stdout: 'job-4212' } }); const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); expect(r.ok).toBe(false); expect(r.reason).toMatch(/restauración en curso/); }); it('el motivo del rechazo nunca queda vacío: cada caso tiene su remedio', async () => { const casos = [ { ...OK, 'test -w': { code: 1, stdout: 'no' } }, { ...OK, 'systemctl show': { code: 0, stdout: 'User=otro\nRestart=always' } }, { ...OK, 'systemctl show': { code: 0, stdout: 'User=srvmid_db\nRestart=no' } }, { ...OK, '/Temp': { code: 0, stdout: 'job-1' } } ]; for (const caso of casos) { const { sftp } = sftpFor(caso); const r = await probeInPlaceUpdate(sftp as never, PREFIX, 'srvmid_db'); expect(r.ok).toBe(false); expect(r.reason.trim().length).toBeGreaterThan(20); } }); });