/** * 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); }); });