Reviewed-on: #25 Co-authored-by: hreyes <hreyes@aduanasoft.com.mx> Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
998 lines
44 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|