Files
CloudRecoveryAS/tests/test_release_metadata.py
hreyes 0ac899f531 fix(install): la actualizacion de Windows decia que funciono y no cambiaba nada
Actualizar un servidor con el agente en una carpeta NO estandar terminaba en
verde y lo dejaba con la version anterior. Todo el camino de Windows
identificaba al agente por NOMBRE, mientras que lo unico que se actualiza se
identifica por RUTA; en cuanto las dos no coincidian, nada fallaba y nada
cambiaba.

- Se alinea la tarea programada con el binario instalado. `Start-ScheduledTask`
  ejecuta la ruta registrada en su accion, no el -Prefix: si difieren, se copiaba
  el binario nuevo en un sitio y se arrancaba el viejo del otro. Ahora se
  reapunta conservando disparador, principal, ajustes y argumentos; si no se
  puede corregir, FALLA — arrancar a sabiendas el binario anterior es peor.
- La confirmacion de arranque mira la RUTA del proceso. Un agente viejo que
  nunca se detuvo satisfacia igual de bien un `Get-Process -Name`. Si la ruta no
  es legible (un proceso de SYSTEM no la expone sin elevacion) se acepta por
  nombre y se avisa, en vez de revertir una actualizacion correcta por falta de
  informacion.
- Corregido Merge-EnvFile con un config\.env de UNA linea: al asignar la salida
  de un `if`, PowerShell desenrolla un array de un elemento a escalar, asi que
  $lines.Count reventaba con Set-StrictMode y la siembra abortaba la instalacion.

Nuevo scripts/emular-actualizacion-windows.ps1: monta un agente falso (un .exe
real que se queda vivo), una instalacion en una carpeta y una tarea apuntando a
otra, corre el instalador y dice si la actualizacion surtio efecto. Sin elevacion
y sin tocar la instalacion real de la maquina. Es lo que destapo los dos
defectos: contra el instalador anterior reproduce el sintoma exacto —codigo de
salida 0 y "El agente esta corriendo con el binario nuevo" sobre un servidor
intacto— y contra este confirma que ya surte efecto, sin tocar la tarea cuando
ya estaba bien.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:28:05 -06:00

386 lines
17 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
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_install_ps1_alinea_la_tarea_con_el_binario_instalado():
"""
El fallo silencioso: reemplazar el binario es una operación por RUTA, pero
`Start-ScheduledTask` es por NOMBRE y ejecuta la ruta que la tarea lleva registrada. Con una
instalación fuera de la carpeta por omisión, eso copiaba el binario nuevo en un sitio y
arrancaba el viejo desde otro: el run terminaba en verde y el servidor seguía igual.
Se comprueba con `scripts/emular-actualizacion-windows.ps1`, que reproduce el escenario
completo; esto solo protege de que las piezas desaparezcan en un refactor.
"""
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert "Sync-AgentTaskPath" in ps1
assert "Get-AgentTaskExecute" in ps1
# La comparación tiene que normalizar: la tarea guarda la ruta entrecomillada y NTFS no
# distingue mayúsculas, así que comparar las cadenas en crudo da falsos negativos.
assert "Test-SamePath" in ps1
# Y la confirmación de arranque tiene que mirar la RUTA del proceso, no solo su nombre.
assert "FromPrefix" in ps1
def test_merge_env_file_no_se_desenrolla_con_una_sola_linea():
"""
`$lines = if (...) { @(Get-Content ...) } else { @() }` desenrolla un array de UN elemento a
escalar al asignarlo, así que con un config\\.env de una sola línea `$lines.Count` reventaba
bajo Set-StrictMode y la siembra de credenciales abortaba la instalación. El `@()` tiene que
envolver el `if` COMPLETO.
"""
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert "$lines = @(\n" in ps1, "el @() debe envolver el if completo, no solo el Get-Content"
assert "$lines = if (" not 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)