Files
PANEL_BASES_ANEXO24/src/lib/server/cras-install.test.ts
hreyes 76138429cf fix/windows-actualizacion-silenciosa (#25)
Reviewed-on: #25
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
2026-07-31 17:16:29 +00:00

998 lines
44 KiB
TypeScript

/**
* 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<string, ((...args: unknown[]) => 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 '<password>' | 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<string, { code?: number; stdout?: string; stderr?: string }>) {
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<typeof verifyWindowsDeployment>
): Promise<string> {
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<string, { code?: number; stdout?: string; stderr?: string }>) {
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<string, { code?: number; stdout?: string; stderr?: string }>) {
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);
}
});
});