Dos bloqueos que impedían avanzar con la instalación en Linux. 1. La validación de PANEL_PUBLIC_URL solo comparaba que coincidiera con ORIGIN, y eso deja pasar el peor caso: que AMBAS valgan localhost, que es justo lo que produce el compose de desarrollo. Ahí no avisaba nada y la instalación sembraba loopback en el config/.env del destino, donde localhost es el propio destino y el agente acabaría hablando consigo mismo. Ahora se valida lo que de verdad importa —que la URL sea alcanzable desde otra máquina— y solo después el desajuste con ORIGIN. El mensaje también dejaba un consejo peligroso: "comenta PANEL_PUBLIC_URL para que tome ORIGIN" solo vale si ORIGIN sirve para sembrar; con ORIGIN en localhost, seguirlo empeora las cosas. Ese consejo ahora es condicional. De paso se corrige el docstring, que afirmaba que un ORIGIN equivocado se habría manifestado por la protección CSRF de adapter-node. No es cierto: svelte.config.js tiene csrf.checkOrigin en false, así que el Origin de los POST nunca se valida y ese razonamiento llevaba a conclusiones falsas. 2. El formulario fuerza mode='update' en cuanto el servidor tiene versión instalada, y una actualización exige que en la ruta destino ya viva algo. Eso hacía imposible mover una instalación a otra carpeta —por ejemplo al home, para instalar sin privilegios—: abortaba con "no hay una instalación". Se agrega una confirmación explícita siguiendo el molde de platformAck: el ack lleva la RUTA confirmada y no un booleano, así que una casilla marcada deja de valer si después se cambia el destino, y el servidor revalida en vez de confiar en la UI. La casilla advierte además que el agente anterior sigue corriendo: los dos reportarían con el mismo instance_key y se pisarían la carpeta de entrada registrada, que es por donde el panel enruta los respaldos. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
331 lines
14 KiB
TypeScript
331 lines
14 KiB
TypeScript
/**
|
|
* Comparación de versiones y normalización de plataforma para CloudRestoreAS.
|
|
*
|
|
* Es la lógica que decide si a un servidor le falta actualizarse, así que los casos que se
|
|
* prueban son los que romperían eso en silencio: comparación lexicográfica (1.10 vs 1.9),
|
|
* versiones de distinta longitud, y valores no comparables.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
compareVersions,
|
|
effectiveArch,
|
|
effectivePlatform,
|
|
installPlatformVerdict,
|
|
isCrasPlatform,
|
|
isCleanInstallConfirmed,
|
|
isInsideWorkFolder,
|
|
unseedablePanelUrlReason,
|
|
isNewer,
|
|
isValidVersion,
|
|
osToPlatform,
|
|
parseVersion,
|
|
platformLabel,
|
|
DEFAULT_ARCH
|
|
} from './cras-version';
|
|
|
|
describe('isValidVersion', () => {
|
|
it('acepta versiones de puntos y números', () => {
|
|
for (const v of ['1', '1.0', '1.1.0', '26.7.1.4']) {
|
|
expect(isValidVersion(v), v).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('rechaza lo que el PANEL no podría ordenar', () => {
|
|
for (const v of ['1.0.0-rc1', 'v1.0.0', '1.0.0a', '', ' ', 'latest', '1.2.3.4.5']) {
|
|
expect(isValidVersion(v), String(v)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('rechaza valores que no son cadena', () => {
|
|
expect(isValidVersion(null)).toBe(false);
|
|
expect(isValidVersion(undefined)).toBe(false);
|
|
expect(isValidVersion(110)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('parseVersion', () => {
|
|
it('descompone en enteros', () => {
|
|
expect(parseVersion('1.10.2')).toEqual([1, 10, 2]);
|
|
expect(parseVersion(' 2.0 ')).toEqual([2, 0]);
|
|
});
|
|
|
|
it('devuelve null si no es comparable', () => {
|
|
expect(parseVersion('1.0.0-rc1')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('compareVersions', () => {
|
|
it('compara numéricamente, no como texto', () => {
|
|
// El error clásico: como cadenas, "1.9.0" > "1.10.0".
|
|
expect(compareVersions('1.10.0', '1.9.0')).toBe(1);
|
|
expect(compareVersions('1.9.0', '1.10.0')).toBe(-1);
|
|
});
|
|
|
|
it('rellena con ceros las de distinta longitud', () => {
|
|
expect(compareVersions('1.2', '1.2.0')).toBe(0);
|
|
expect(compareVersions('1.2.1', '1.2')).toBe(1);
|
|
expect(compareVersions('2', '1.9.9.9')).toBe(1);
|
|
});
|
|
|
|
it('es simétrica', () => {
|
|
expect(compareVersions('1.1.0', '1.1.0')).toBe(0);
|
|
expect(compareVersions('26.7.1.4', '26.7.1.3')).toBe(1);
|
|
expect(compareVersions('26.7.1.3', '26.7.1.4')).toBe(-1);
|
|
});
|
|
|
|
it('devuelve null cuando alguna no es comparable', () => {
|
|
expect(compareVersions('1.0.0-rc1', '1.0.0')).toBeNull();
|
|
expect(compareVersions('1.0.0', 'latest')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('isNewer', () => {
|
|
it('detecta una versión más nueva', () => {
|
|
expect(isNewer('1.2.0', '1.1.0')).toBe(true);
|
|
expect(isNewer('1.10.0', '1.9.0')).toBe(true);
|
|
});
|
|
|
|
it('no ofrece downgrade ni reinstalación de la misma', () => {
|
|
expect(isNewer('1.1.0', '1.2.0')).toBe(false);
|
|
expect(isNewer('1.1.0', '1.1.0')).toBe(false);
|
|
});
|
|
|
|
it('sin versión instalada, hay algo que instalar', () => {
|
|
expect(isNewer('1.1.0', null)).toBe(true);
|
|
expect(isNewer('1.1.0', undefined)).toBe(true);
|
|
expect(isNewer('1.1.0', ' ')).toBe(true);
|
|
});
|
|
|
|
it('ante una versión no comparable prefiere NO ofrecer nada', () => {
|
|
// Preferible perderse una actualización que empujar un downgrade por un dato sucio.
|
|
expect(isNewer('1.0.0-rc1', '1.0.0')).toBe(false);
|
|
expect(isNewer('1.2.0', 'desconocida')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('osToPlatform', () => {
|
|
it('reconoce Windows en texto libre', () => {
|
|
for (const os of ['Windows Server 2019', 'windows 11', 'WIN SERVER 2022', 'Win']) {
|
|
expect(osToPlatform(os), os).toBe('windows');
|
|
}
|
|
});
|
|
|
|
it('reconoce distribuciones de Linux', () => {
|
|
for (const os of [
|
|
'Ubuntu 22.04',
|
|
'Debian 12',
|
|
'CentOS 7',
|
|
'RHEL 9',
|
|
'Red Hat Enterprise',
|
|
'Rocky Linux 9',
|
|
'AlmaLinux',
|
|
'SUSE',
|
|
'Oracle Linux 8'
|
|
]) {
|
|
expect(osToPlatform(os), os).toBe('linux');
|
|
}
|
|
});
|
|
|
|
it('devuelve null si no puede decidir, en lugar de adivinar', () => {
|
|
// Adivinar mandaría el binario equivocado; la UI pide elegir a mano.
|
|
for (const os of ['', ' ', null, undefined, 'servidor de la sucursal', 'macOS 14']) {
|
|
expect(osToPlatform(os as string), String(os)).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('effectivePlatform', () => {
|
|
it('lo reportado por el agente manda sobre el texto libre', () => {
|
|
expect(effectivePlatform('linux', 'Windows Server 2019')).toBe('linux');
|
|
expect(effectivePlatform('windows', 'Ubuntu 22.04')).toBe('windows');
|
|
});
|
|
|
|
it('cae al texto libre cuando el agente no reportó', () => {
|
|
expect(effectivePlatform(null, 'Ubuntu 22.04')).toBe('linux');
|
|
expect(effectivePlatform(' ', 'Windows Server 2022')).toBe('windows');
|
|
});
|
|
|
|
it('ignora una plataforma reportada inválida', () => {
|
|
expect(effectivePlatform('solaris', 'Ubuntu 22.04')).toBe('linux');
|
|
expect(effectivePlatform('solaris', null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('effectiveArch', () => {
|
|
it('usa la reportada y normaliza a minúsculas', () => {
|
|
expect(effectiveArch('X86_64')).toBe('x86_64');
|
|
expect(effectiveArch('arm64')).toBe('arm64');
|
|
});
|
|
|
|
it('asume la única arquitectura que se publica hoy si falta', () => {
|
|
expect(effectiveArch(null)).toBe(DEFAULT_ARCH);
|
|
expect(effectiveArch(' ')).toBe(DEFAULT_ARCH);
|
|
});
|
|
});
|
|
|
|
describe('isCrasPlatform y platformLabel', () => {
|
|
it('valida el vocabulario del CHECK de cras_releases', () => {
|
|
expect(isCrasPlatform('windows')).toBe(true);
|
|
expect(isCrasPlatform('linux')).toBe(true);
|
|
expect(isCrasPlatform('solaris')).toBe(false);
|
|
expect(isCrasPlatform(null)).toBe(false);
|
|
});
|
|
|
|
it('etiqueta legible, incluso sin dato', () => {
|
|
expect(platformLabel('windows')).toBe('Windows');
|
|
expect(platformLabel('linux')).toBe('Linux');
|
|
expect(platformLabel(null)).toBe('Sin determinar');
|
|
});
|
|
});
|
|
|
|
describe('installPlatformVerdict', () => {
|
|
it('con plataforma conocida solo deja pasar el artefacto que corresponde', () => {
|
|
expect(installPlatformVerdict('linux', 'linux', null)).toBe('ok');
|
|
expect(installPlatformVerdict('windows', 'linux', null)).toBe('mismatch');
|
|
expect(installPlatformVerdict('linux', 'windows', null)).toBe('mismatch');
|
|
});
|
|
|
|
it('una confirmación no puede pasar por encima de una plataforma conocida', () => {
|
|
// El destino es Windows y el operador confirma "linux": sigue siendo un desajuste.
|
|
expect(installPlatformVerdict('windows', 'linux', 'linux')).toBe('mismatch');
|
|
});
|
|
|
|
it('sin plataforma determinada exige confirmación explícita del operador', () => {
|
|
// El defecto que se corrige: el modal preseleccionaba el primer artefacto del catálogo
|
|
// (el más recién descubierto, hoy Linux) para un servidor "Sin determinar".
|
|
expect(installPlatformVerdict(null, 'linux', null)).toBe('needs-ack');
|
|
expect(installPlatformVerdict(null, 'linux', '')).toBe('needs-ack');
|
|
expect(installPlatformVerdict(undefined, 'windows', undefined)).toBe('needs-ack');
|
|
});
|
|
|
|
it('la confirmación tiene que coincidir con la plataforma del artefacto', () => {
|
|
expect(installPlatformVerdict(null, 'linux', 'linux')).toBe('ok');
|
|
expect(installPlatformVerdict(null, 'linux', 'windows')).toBe('needs-ack');
|
|
expect(installPlatformVerdict(null, 'windows', ' WINDOWS ')).toBe('ok');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Guard de la ruta de instalación contra las carpetas de trabajo del agente.
|
|
*
|
|
* No tenía ni una prueba, y con la relación mirada en los dos sentidos rechazaba el layout
|
|
* NORMAL —`DIR_ENTRADA = APP_DIR / "Entrada"` en constants.py del agente— así que ninguna
|
|
* instalación ni actualización podía pasar. Se salvaban solo las instalaciones fuera de la ruta
|
|
* por omisión, por accidente del nombre.
|
|
*/
|
|
describe('isInsideWorkFolder', () => {
|
|
it('acepta el layout normal: las carpetas de trabajo cuelgan de la de instalación', () => {
|
|
expect(
|
|
isInsideWorkFolder('/opt/cloudrestoreas', [
|
|
'/opt/cloudrestoreas/Entrada',
|
|
'/opt/cloudrestoreas/Procesados'
|
|
])
|
|
).toBe(false);
|
|
expect(
|
|
isInsideWorkFolder('C:\\Aduanasoft\\CloudRestoreAS', [
|
|
'C:\\Aduanasoft\\CloudRestoreAS\\Entrada'
|
|
])
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rechaza el binario DENTRO de una carpeta de trabajo, que es el peligro real', () => {
|
|
// Ahí el agente tomaría su propio ejecutable por un respaldo a procesar.
|
|
expect(isInsideWorkFolder('/srv/entrada/cras', ['/srv/entrada'])).toBe(true);
|
|
expect(
|
|
isInsideWorkFolder('D:\\Respaldos\\Entrada\\app', ['D:\\Respaldos\\Entrada'])
|
|
).toBe(true);
|
|
});
|
|
|
|
it('rechaza instalar exactamente EN la carpeta de trabajo', () => {
|
|
expect(isInsideWorkFolder('/srv/entrada', ['/srv/entrada'])).toBe(true);
|
|
expect(isInsideWorkFolder('/srv/entrada/', ['/srv/entrada'])).toBe(true);
|
|
});
|
|
|
|
it('normaliza separadores, barra final y caja antes de comparar', () => {
|
|
expect(isInsideWorkFolder('C:/Respaldos/Entrada/app', ['c:\\respaldos\\entrada\\'])).toBe(
|
|
true
|
|
);
|
|
});
|
|
|
|
it('un prefijo que no es de carpeta no cuenta como estar dentro', () => {
|
|
// Es el caso que dejaba pasar a Principal: -win rompe el prefijo de carpeta.
|
|
expect(
|
|
isInsideWorkFolder('C:\\Aduanasoft\\CloudRestoreAS', [
|
|
'C:\\Aduanasoft\\CloudRestoreAS-win\\Entrada'
|
|
])
|
|
).toBe(false);
|
|
expect(isInsideWorkFolder('/srv/entradas', ['/srv/entrada'])).toBe(false);
|
|
});
|
|
|
|
it('ignora carpetas vacías o sin reportar en lugar de bloquear', () => {
|
|
expect(isInsideWorkFolder('/opt/cloudrestoreas', [null, undefined, '', ' '])).toBe(false);
|
|
expect(isInsideWorkFolder('', ['/srv/entrada'])).toBe(false);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* URL del panel que se siembra en el agente.
|
|
*
|
|
* El aviso original solo comparaba que PANEL_PUBLIC_URL y ORIGIN coincidieran, y eso deja pasar
|
|
* el peor caso: que ambas valgan localhost, que es justo lo que produce el compose de desarrollo.
|
|
* Lo que hay que exigir es que la URL sea alcanzable desde OTRA máquina.
|
|
*/
|
|
describe('unseedablePanelUrlReason', () => {
|
|
it('rechaza loopback en todas sus formas, aunque coincida con ORIGIN', () => {
|
|
// Es el hueco que se está cerrando: en el servidor destino, localhost es ese servidor.
|
|
expect(unseedablePanelUrlReason('https://localhost:3000')).toMatch(/localhost/);
|
|
expect(unseedablePanelUrlReason('http://127.0.0.1:3000')).toMatch(/127\.0\.0\.1/);
|
|
expect(unseedablePanelUrlReason('http://127.1.2.3:3000')).not.toBeNull();
|
|
expect(unseedablePanelUrlReason('http://[::1]:3000')).not.toBeNull();
|
|
expect(unseedablePanelUrlReason('http://0.0.0.0:3000')).not.toBeNull();
|
|
});
|
|
|
|
it('acepta una URL alcanzable, con y sin puerto', () => {
|
|
expect(unseedablePanelUrlReason('https://cpanel-a24.aduanasoft.com:3000')).toBeNull();
|
|
// Sin puerto también pasa: el problema de esa es el desajuste con ORIGIN, que es un
|
|
// aviso distinto. Mezclar los dos haría imposible saber cuál corregir.
|
|
expect(unseedablePanelUrlReason('https://cpanel-a24.aduanasoft.com')).toBeNull();
|
|
expect(unseedablePanelUrlReason('http://10.0.0.5:3000')).toBeNull();
|
|
});
|
|
|
|
it('rechaza vacío, basura y protocolos que el agente no habla', () => {
|
|
expect(unseedablePanelUrlReason('')).toMatch(/no está configurada/);
|
|
expect(unseedablePanelUrlReason(null)).toMatch(/no está configurada/);
|
|
expect(unseedablePanelUrlReason('cpanel-a24.aduanasoft.com:3000')).not.toBeNull();
|
|
expect(unseedablePanelUrlReason('ftp://cpanel-a24.aduanasoft.com')).toMatch(/ftp/);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Confirmación de instalación limpia en otra ruta.
|
|
*
|
|
* Sin esto no se puede mover una instalación existente —por ejemplo al home, para instalar sin
|
|
* privilegios—: el panel pide `update` en cuanto hay versión instalada y assertExistingInstall
|
|
* exige que ya viva algo en la ruta destino.
|
|
*/
|
|
describe('isCleanInstallConfirmed', () => {
|
|
it('solo confirma si el ack es exactamente la ruta pedida', () => {
|
|
expect(isCleanInstallConfirmed('/home/srvmid_db/cras', '/home/srvmid_db/cras')).toBe(true);
|
|
expect(isCleanInstallConfirmed('/home/srvmid_db/cras', '/opt/cloudrestoreas')).toBe(false);
|
|
});
|
|
|
|
it('un ack de otra ruta NO sirve: la casilla no queda encendida si se cambia el destino', () => {
|
|
// Es la razón de que el ack lleve la ruta y no un booleano.
|
|
expect(isCleanInstallConfirmed('/home/otro/cras', '/home/srvmid_db/cras')).toBe(false);
|
|
expect(isCleanInstallConfirmed('/opt/cloudrestoreas', 'true')).toBe(false);
|
|
expect(isCleanInstallConfirmed('/opt/cloudrestoreas', 'on')).toBe(false);
|
|
});
|
|
|
|
it('tolera el separador final y los espacios, que no cambian la ruta', () => {
|
|
expect(isCleanInstallConfirmed('/home/srvmid_db/cras', '/home/srvmid_db/cras/')).toBe(true);
|
|
expect(isCleanInstallConfirmed('C:\\Aduanasoft\\CRAS', ' C:\\Aduanasoft\\CRAS\\ ')).toBe(true);
|
|
});
|
|
|
|
it('sin ack, o sin ruta pedida, nunca confirma', () => {
|
|
expect(isCleanInstallConfirmed('/opt/cloudrestoreas', null)).toBe(false);
|
|
expect(isCleanInstallConfirmed('/opt/cloudrestoreas', '')).toBe(false);
|
|
expect(isCleanInstallConfirmed('', '')).toBe(false);
|
|
expect(isCleanInstallConfirmed(null, null)).toBe(false);
|
|
});
|
|
});
|