292 lines
12 KiB
Python
292 lines
12 KiB
Python
"""
|
|
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)
|