""" Pruebas del contrato de release: versión, artefactos y manifiesto. Lo que se protege aquí es la cadena que hace posible la distribución automatizada: app/__init__.py es la fuente única de la versión, package-release.sh la usa para nombrar los artefactos y armar release.json, y el PANEL compara versiones como tuplas de enteros para saber si hay una más nueva. Un formato de versión distinto rompe esa comparación en silencio, así que se valida el formato, no solo que exista. """ import json import os import re import subprocess import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parent.parent # Mismo patrón que valida package-release.sh y que el PANEL puede ordenar. VERSION_RE = re.compile(r"^\d+(\.\d+){1,3}$") def read_version_from_source() -> str: """Lee __version__ del archivo, sin importar el paquete (igual que el spec).""" text = (ROOT / "app" / "__init__.py").read_text(encoding="utf-8") match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE) assert match, "no se encontró __version__ en app/__init__.py" return match.group(1) def test_version_tiene_formato_comparable(): assert VERSION_RE.match(read_version_from_source()) def test_version_del_paquete_coincide_con_el_archivo(): # El spec de PyInstaller y package-release.sh leen el archivo con regex; el resto de # la app importa app.__version__. Ambos caminos deben dar lo mismo. from app import __version__ assert __version__ == read_version_from_source() def test_ui_no_hardcodea_la_version(): # El diálogo "Acerca de" traía la versión literal y quedó desfasado del paquete. text = (ROOT / "app" / "ui" / "main_window.py").read_text(encoding="utf-8") assert "__version__" in text assert not re.search(r"CloudRestoreAS v\d+\.\d+\.\d+", text) def test_bundled_versions_es_json_valido(): # El bootstrap calcula el sha256 de este archivo para decidir si re-despliega # config/7zip y config/odbc; si no es JSON válido el build queda inconsistente. manifest = ROOT / "packaging" / "bundled-versions.json" data = json.loads(manifest.read_text(encoding="utf-8")) assert data.get("seven_zip"), "falta la versión de 7-Zip" def test_spec_inyecta_metadatos_de_version_en_windows(): spec = (ROOT / "packaging" / "CloudRestoreAS.spec").read_text(encoding="utf-8") assert "version=_version_info" in spec, "el EXE debe recibir el recurso de versión" assert "bundled-versions.json" in spec, "bundled-versions.json debe ir embebido" def test_instalador_de_despliegue_y_script_de_desarrollo_estan_separados(): """ scripts/dev-setup.ps1 prepara un venv de desarrollo y no tiene nada que hacer en el zip del ejecutable autocontenido; install.ps1 es el instalador de despliegue. Antes eran el mismo archivo y se empaquetaba el de desarrollo. """ script = (ROOT / "packaging" / "scripts" / "package-release.sh").read_text(encoding="utf-8") assert '(root / "install.ps1", "CloudRestoreAS/install.ps1")' in script assert "CloudRestoreAS/dev-setup.ps1" not in script dev_setup = ROOT / "scripts" / "dev-setup.ps1" installer = ROOT / "install.ps1" assert dev_setup.is_file() and installer.is_file() # El de desarrollo crea venv; el de despliegue registra el arranque automático. assert "venv" in dev_setup.read_text(encoding="utf-8") assert "Register-ScheduledTask" in installer.read_text(encoding="utf-8") def test_instaladores_aceptan_panel_env_file(): """ El instalador remoto del PANEL siembra las credenciales por archivo, no por argv, para que el token no quede visible en `ps` ni en el historial del destino. """ sh = (ROOT / "install.sh").read_text(encoding="utf-8") ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8") assert "--panel-env-file" in sh assert "PanelEnvFile" in ps1 # Lista blanca de claves en ambos: el archivo llega por la red. for text in (sh, ps1): assert "CLOUDRESTORE_PANEL_API_TOKEN" in text assert "CLOUDRESTORE_PANEL_INSTANCE_KEY" in text def test_ambos_instaladores_reservan_el_75_para_restauracion_en_curso(): """ 75 (EX_TEMPFAIL) es el contrato con el PANEL: significa "reintenta luego", no "falló la instalación", y el panel lo traduce a un 409 amable. Con el código genérico, el operador salía a investigar una avería inexistente mientras el respaldo que se estaba restaurando quedaba vetado y la base en SINGLE_USER. Windows no tenía esta guarda: reinstalar se llevaba por delante la restauración en curso. """ sh = (ROOT / "install.sh").read_text(encoding="utf-8") ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8") assert "exit 75" in sh assert "EXIT_RESTORE_IN_PROGRESS = 75" in ps1 assert "exit $EXIT_RESTORE_IN_PROGRESS" in ps1 # La señal de "hay un job en vuelo" es la misma en ambos: Temp/ no vacío. for text in (sh, ps1): assert "Temp" in text def test_install_ps1_acepta_update_in_place(): """El PANEL actualiza con -UpdateInPlace; sin el parámetro, la actualización aborta.""" ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8") assert "[switch]$UpdateInPlace" in ps1 # Y debe saltarse el bootstrap: una segunda instancia purga Temp/ de la que está viva. assert "$Mode -ne 'update-in-place'" in ps1 def test_install_ps1_respalda_y_revierte(): """ Reemplazar el binario de un servidor en producción sin red de seguridad significa que un binario que no arranca deja el servidor sin restaurador y sin forma de recuperarlo salvo entrando a mano. install.sh ya respaldaba y revertía; install.ps1 no. """ ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8") assert ".$BinName.prev" in ps1 assert "Wait-AgentAlive" in ps1 # El respaldo solo se descarta tras confirmar que la versión nueva corre. assert "Remove-Item -LiteralPath $backup" in ps1 def test_el_arranque_automatico_de_windows_pide_headless(): """ La tarea ONSTART corre como SYSTEM, en la sesión 0, donde no hay escritorio interactivo. Es `--headless` lo que hace que el binario elija el plugin Qt 'offscreen'; sin esa bandera Qt intenta el plugin 'windows' y el agente no levanta. Se prueba el contrato COMPLETO —quien lanza y quien recibe— porque el defecto original fue justamente que las dos mitades no coincidían: install.ps1 documentaba en un comentario que fijaba QT_QPA_PLATFORM y no lo hacía, y runner.py ignoraba --headless en Windows. """ ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8") runner_py = (ROOT / "runner.py").read_text(encoding="utf-8") assert "-Argument '--start-engine --headless'" in ps1 # El binario tiene que honrar la bandera ANTES de mirar la plataforma; si el early-return de # win32/darwin vuelve a quedar primero, --headless deja de hacer nada en Windows. headless_at = runner_py.index('if headless:\n os.environ["QT_QPA_PLATFORM"]') win32_at = runner_py.index('if sys.platform in ("win32", "darwin"):') assert headless_at < win32_at @pytest.mark.skipif(sys.platform == "win32", reason="usa bash y sha256sum") def test_package_release_genera_manifiesto_consistente(tmp_path: Path): """ Corre package-release.sh contra un árbol mínimo con binarios simulados y verifica que release.json y SHA256SUMS concuerden entre sí y con los archivos en disco. """ for rel in ( "app/__init__.py", "packaging/scripts/package-release.sh", "packaging/LEEME.txt", "packaging/bundled-versions.json", "packaging/linux/cloudrestoreas.service", "install.sh", "install.ps1", ): dest = tmp_path / rel dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes((ROOT / rel).read_bytes()) dist = tmp_path / "dist" dist.mkdir() (dist / "CloudRestoreAS").write_text("ELF simulado\n", encoding="utf-8") (dist / "CloudRestoreAS.exe").write_text("PE simulado\n", encoding="utf-8") result = subprocess.run( ["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")], capture_output=True, text=True, # Los binarios simulados son de unos bytes; se desactiva el piso de tamaño, que se # prueba aparte en test_package_release_rechaza_binario_truncado. env={**os.environ, "CLOUDRESTORE_MIN_BINARY_MB": "0"}, ) assert result.returncode == 0, f"package-release.sh falló:\n{result.stderr}" release_dir = dist / "release" manifest = json.loads((release_dir / "release.json").read_text(encoding="utf-8")) version = read_version_from_source() assert manifest["version"] == version assert manifest["product"] == "CloudRestoreAS" assert manifest["bundled"]["seven_zip"], "el manifiesto debe registrar las deps embebidas" platforms = {a["platform"] for a in manifest["artifacts"]} assert platforms == {"linux", "windows"} # Cada artefacto: existe, el nombre lleva versión y plataforma, y el sha256/tamaño # del manifiesto coinciden con el archivo real. import hashlib sums = dict( reversed(line.split(maxsplit=1)) for line in (release_dir / "SHA256SUMS").read_text(encoding="utf-8").splitlines() if line.strip() ) for artifact in manifest["artifacts"]: path = release_dir / artifact["file_name"] assert path.is_file(), f"falta el artefacto {artifact['file_name']}" assert version in artifact["file_name"] assert artifact["arch"] in artifact["file_name"] assert path.stat().st_size == artifact["size"] assert hashlib.sha256(path.read_bytes()).hexdigest() == artifact["sha256"] # SHA256SUMS y release.json no deben poder divergir. assert sums[path.name.strip()].strip() == artifact["sha256"] # Contenido de los paquetes: el instalador de despliegue va dentro, el script de # desarrollo NO (antes se empaquetaba dev-setup.ps1 junto al .exe autocontenido). import tarfile import zipfile win_pkg = next(a for a in manifest["artifacts"] if a["platform"] == "windows") with zipfile.ZipFile(release_dir / win_pkg["file_name"]) as zf: names = set(zf.namelist()) assert "CloudRestoreAS/CloudRestoreAS.exe" in names assert "CloudRestoreAS/install.ps1" in names assert not any("dev-setup" in n for n in names) linux_pkg = next(a for a in manifest["artifacts"] if a["platform"] == "linux") with tarfile.open(release_dir / linux_pkg["file_name"]) as tf: members = set(tf.getnames()) # El instalador remoto extrae y corre CloudRestoreAS/install.sh, que a su vez busca # el binario y la unit systemd relativos a su ubicación. assert "CloudRestoreAS/CloudRestoreAS-linux" in members assert "CloudRestoreAS/install.sh" in members assert "CloudRestoreAS/packaging/linux/cloudrestoreas.service" in members def _stage_package_tree(tmp_path: Path) -> Path: """Árbol mínimo para correr package-release.sh. Devuelve dist/.""" for rel in ( "app/__init__.py", "packaging/scripts/package-release.sh", "packaging/LEEME.txt", "packaging/bundled-versions.json", "packaging/linux/cloudrestoreas.service", "install.sh", "install.ps1", ): dest = tmp_path / rel dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes((ROOT / rel).read_bytes()) dist = tmp_path / "dist" (dist / "release").mkdir(parents=True, exist_ok=True) return dist def _run_package(tmp_path: Path, min_mb: str = "1") -> subprocess.CompletedProcess: return subprocess.run( ["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")], capture_output=True, text=True, env={**os.environ, "CLOUDRESTORE_MIN_BINARY_MB": min_mb}, ) @pytest.mark.skipif(sys.platform == "win32", reason="usa bash") def test_package_release_ignora_binario_rancio_en_release(tmp_path: Path): """ Un binario que quedó en dist/release/ de una corrida anterior NO debe empaquetarse. Así se generó una vez un zip etiquetado 1.1.0 con un .exe parcial de 4.9 MB: el build de Windows había fallado, pero el empaquetado tomaba la copia vieja de dist/release/ en lugar del binario recién compilado en dist/. El sha256 y el release.json quedaban consistentes con los bytes equivocados, así que la verificación de integridad no lo detectaba. """ dist = _stage_package_tree(tmp_path) (dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024)) # Linux sí compiló # Sobrante de una corrida previa; dist/CloudRestoreAS.exe NO existe. (dist / "release" / "CloudRestoreAS.exe").write_bytes(b"parcial" * 1000) result = _run_package(tmp_path) assert result.returncode == 0, result.stderr manifest = json.loads((dist / "release" / "release.json").read_text(encoding="utf-8")) assert [a["platform"] for a in manifest["artifacts"]] == ["linux"] assert not list((dist / "release").glob("*win*.zip")), "no debió empaquetar Windows" # La copia rancia se elimina para que no reaparezca en la siguiente corrida. assert not (dist / "release" / "CloudRestoreAS.exe").exists() @pytest.mark.skipif(sys.platform == "win32", reason="usa bash") def test_package_release_rechaza_binario_truncado(tmp_path: Path): """Un build a medias debe abortar el empaquetado completo, no publicarse.""" dist = _stage_package_tree(tmp_path) (dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024)) (dist / "CloudRestoreAS.exe").write_bytes(b"x" * 1024) # muy por debajo del piso result = _run_package(tmp_path, min_mb="1") assert result.returncode != 0 salida = result.stdout + result.stderr assert "truncado" in salida assert "abortado" in salida # Nada debe quedar publicable si alguna plataforma es inválida. assert not (dist / "release" / "release.json").exists() @pytest.mark.skipif(sys.platform == "win32", reason="usa bash") def test_package_release_rechaza_binario_anterior_al_bump(tmp_path: Path): """ Un binario más viejo que app/__init__.py pertenece a otra versión. Empaquetarlo con el nombre de la versión actual publicaría una mentira que el sha256 no puede delatar. """ dist = _stage_package_tree(tmp_path) (dist / "CloudRestoreAS").write_bytes(b"x" * (2 * 1024 * 1024)) exe = dist / "CloudRestoreAS.exe" exe.write_bytes(b"x" * (2 * 1024 * 1024)) os.utime(exe, (0, 0)) # 1970: anterior a cualquier cambio de versión result = _run_package(tmp_path) assert result.returncode != 0 salida = result.stdout + result.stderr assert "MÁS VIEJO" in salida assert not (dist / "release" / "release.json").exists() @pytest.mark.skipif(sys.platform == "win32", reason="usa bash") def test_package_release_rechaza_version_invalida(tmp_path: Path): """Una versión no comparable debe abortar el empaquetado, no publicarse.""" for rel in ("packaging/scripts/package-release.sh", "packaging/LEEME.txt"): dest = tmp_path / rel dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes((ROOT / rel).read_bytes()) (tmp_path / "app").mkdir() (tmp_path / "app" / "__init__.py").write_text('__version__ = "1.0.0-rc1"\n', encoding="utf-8") (tmp_path / "dist").mkdir() result = subprocess.run( ["bash", str(tmp_path / "packaging" / "scripts" / "package-release.sh")], capture_output=True, text=True, ) assert result.returncode != 0 assert "inválida" in (result.stdout + result.stderr)