""" 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 @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)