feature/generador-instaladores-linux-windows

This commit is contained in:
2026-07-30 07:34:17 -06:00
parent cafe3f1b87
commit c3f1d70e23
34 changed files with 3498 additions and 252 deletions

View File

@@ -77,3 +77,111 @@ def test_apply_env_overrides_paths(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CLOUDRESTORE_INPUT_FOLDER", "/tmp/in")
cfg = apply_env_overrides(DEFAULT_CONFIG.copy())
assert cfg["paths"]["input_folder"] == "/tmp/in"
def _redirect_bootstrap(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Reapunta los globales de bootstrap a un árbol temporal. Devuelve config/."""
for name, rel in (
("APP_DIR", ""),
("CONFIG_DIR", "config"),
("DATA_DIR", "config/data"),
("LOGS_DIR", "config/logs"),
("ODBC_DIR", "config/odbc"),
("SEVEN_ZIP_DIR", "config/7zip"),
("ENV_PATH", "config/.env"),
("DIR_ENTRADA", "Entrada"),
("DIR_PROCESADOS", "Procesados"),
("DIR_FALLADOS", "Fallados"),
("DIR_TEMP", "Temp"),
):
target = tmp_path / rel if rel else tmp_path
monkeypatch.setattr(f"app.config.bootstrap.{name}", target)
monkeypatch.setattr("app.constants.DB_PATH", tmp_path / "config" / "data" / "app.db")
return tmp_path / "config"
def test_bootstrap_escribe_sello_de_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""
El instalador remoto del PANEL lee config/.version por SFTP para verificar el
despliegue: en Windows el .exe se compila con console=False y no hay stdout confiable.
"""
from app import __version__
config_dir = _redirect_bootstrap(tmp_path, monkeypatch)
ensure_runtime_layout()
assert (config_dir / ".version").read_text(encoding="utf-8").strip() == __version__
def test_bootstrap_redespliega_deps_cuando_cambia_el_manifiesto(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""
config/7zip y config/odbc se re-copian cuando el build trae otras versiones embebidas.
Antes solo se copiaban si la carpeta estaba vacía, así que una actualización con driver
ODBC nuevo conservaba el viejo indefinidamente.
"""
config_dir = _redirect_bootstrap(tmp_path, monkeypatch)
src_7zip = tmp_path / "bundle" / "7zip"
src_odbc = tmp_path / "bundle" / "odbc"
src_7zip.mkdir(parents=True)
src_odbc.mkdir(parents=True)
(src_7zip / "7zz").write_text("7zz v26", encoding="utf-8")
(src_odbc / "odbcinst.ini").write_text("odbc 18.5", encoding="utf-8")
monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_7ZIP", src_7zip)
monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_ODBC", src_odbc)
# bundled-versions.json se busca bajo BUNDLE_DIR/packaging (va embebido en el onefile).
bundle_root = tmp_path / "bundle_root"
(bundle_root / "packaging").mkdir(parents=True)
manifest = bundle_root / "packaging" / "bundled-versions.json"
manifest.write_text('{"seven_zip": "26.01"}', encoding="utf-8")
monkeypatch.setattr("app.config.bootstrap.BUNDLE_DIR", bundle_root)
ensure_runtime_layout()
assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "7zz v26"
stamp_before = (config_dir / ".bundled_deps").read_text(encoding="utf-8").strip()
assert stamp_before.startswith("sha256:")
# Mismo manifiesto: no debe re-copiar (no pisa ajustes locales en cada arranque).
(config_dir / "7zip" / "7zz").write_text("editado a mano", encoding="utf-8")
ensure_runtime_layout()
assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "editado a mano"
# Manifiesto distinto (build con deps nuevas): debe re-copiar.
(src_7zip / "7zz").write_text("7zz v27", encoding="utf-8")
(src_odbc / "odbcinst.ini").write_text("odbc 19.0", encoding="utf-8")
manifest.write_text('{"seven_zip": "27.00"}', encoding="utf-8")
ensure_runtime_layout()
assert (config_dir / "7zip" / "7zz").read_text(encoding="utf-8") == "7zz v27"
assert (config_dir / "odbc" / "odbcinst.ini").read_text(encoding="utf-8") == "odbc 19.0"
assert (config_dir / ".bundled_deps").read_text(encoding="utf-8").strip() != stamp_before
# El re-despliegue SOBRESCRIBE, así que lo que el operador hubiera editado a mano debe
# quedar respaldado: no es reconstruible.
assert (config_dir / "7zip.bak" / "7zz").read_text(encoding="utf-8") == "editado a mano"
assert (config_dir / "odbc.bak" / "odbcinst.ini").read_text(encoding="utf-8") == "odbc 18.5"
def test_bootstrap_no_respalda_cuando_no_hay_refresco(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""Sin cambio de manifiesto no se re-copia, así que tampoco debe crearse el .bak."""
config_dir = _redirect_bootstrap(tmp_path, monkeypatch)
src_7zip = tmp_path / "bundle" / "7zip"
src_7zip.mkdir(parents=True)
(src_7zip / "7zz").write_text("7zz v26", encoding="utf-8")
monkeypatch.setattr("app.config.bootstrap.BUNDLED_SOURCE_7ZIP", src_7zip)
bundle_root = tmp_path / "bundle_root"
(bundle_root / "packaging").mkdir(parents=True)
(bundle_root / "packaging" / "bundled-versions.json").write_text(
'{"seven_zip": "26.01"}', encoding="utf-8"
)
monkeypatch.setattr("app.config.bootstrap.BUNDLE_DIR", bundle_root)
ensure_runtime_layout()
ensure_runtime_layout() # segunda vez: mismo manifiesto, no debe refrescar
assert not (config_dir / "7zip.bak").exists()

View File

@@ -0,0 +1,170 @@
"""
Regresión del "falso fallo" en el reenvío por SFTP.
Escenario reportado: el SFTP SÍ entrega el archivo, pero un error POSTERIOR a la entrega
(p.ej. al mover el ZIP a Procesados) degradaba el job a FAILED y lo reportaba como 'failed'
al panel, aunque el respaldo ya había llegado al destino. La corrección hace que la entrega
exitosa sea el punto de no retorno: el job queda COMPLETED/forwarded y el error posterior
solo se registra, sin caer a Fallados ni reportar 'failed'.
"""
import pytest
from app.constants import JobStatus
from app.engine.restore_worker import RestoreWorker
FORWARD_ROUTE = {
"action": "forward",
"db_name": "DB1",
"node_key": "NODO",
"target": {
"id": 7,
"name": "Omega",
"ssh_host": "h",
"ssh_username": "u",
"ssh_password": "p",
"input_folder": "D:\\In",
},
}
@pytest.fixture
def base_config(tmp_path):
return {
"paths": {
"input_folder": str(tmp_path / "in"),
"processed_folder": str(tmp_path / "processed"),
"failed_folder": str(tmp_path / "failed"),
"extract_folder": str(tmp_path / "extract"),
"data_sql_folder": str(tmp_path / "data"),
"seven_zip_exe": "C:\\Program Files\\7-Zip\\7z.exe",
},
"sql": {"server": "localhost", "use_windows_auth": True},
"timeouts": {"extract_minutes": 30, "restore_minutes": 60},
"panel": {
"api_url": "http://panel:3000",
"api_token": "tok",
"instance_key": "Alfa",
},
}
class FakeJob:
source_name = "NODO.ZIP"
source_path = "C:\\in\\NODO.ZIP"
db_name = "DB1"
node_name = "NODO"
def _wire_common(monkeypatch, statuses, reported):
"""Mockea las dependencias del worker comunes a los dos escenarios."""
monkeypatch.setattr(
"app.engine.restore_worker.JobRepository.get", lambda job_id: FakeJob()
)
monkeypatch.setattr(
"app.engine.restore_worker.JobRepository.update_node_and_db",
lambda *a, **k: None,
)
monkeypatch.setattr(
"app.engine.restore_worker.JobRepository.update_timing", lambda *a, **k: None
)
monkeypatch.setattr(
"app.engine.restore_worker.JobRepository.update_status",
lambda job_id, status, **k: statuses.append(status),
)
monkeypatch.setattr(
"app.engine.restore_worker.JobRepository.delete", lambda *a, **k: None
)
monkeypatch.setattr(
"app.engine.restore_worker.JobStepRepository.create", lambda *a, **k: 1
)
monkeypatch.setattr(
"app.engine.restore_worker.JobStepRepository.complete", lambda *a, **k: None
)
monkeypatch.setattr(
"app.engine.restore_worker.EventRepository.create", lambda *a, **k: None
)
monkeypatch.setattr(
"app.engine.restore_worker.panel_client.resolve_route",
lambda *a, **k: FORWARD_ROUTE,
)
monkeypatch.setattr(
"app.engine.restore_worker.panel_client.report_job_result",
lambda **k: reported.append(k.get("status")),
)
def test_error_post_entrega_no_degrada_a_fallido(monkeypatch, base_config):
"""Entrega OK + error al mover a Procesados => job forwarded, nunca failed."""
statuses: list[str] = []
reported: list[str] = []
_wire_common(monkeypatch, statuses, reported)
# El SFTP entrega con éxito.
monkeypatch.setattr(
"app.engine.restore_worker.sftp_copy.upload_zip_parts",
lambda *a, **k: ["D:/In/NODO.ZIP"],
)
moved_to_failed = {"called": False}
monkeypatch.setattr(
RestoreWorker,
"_move_zip_to_failed",
lambda self, job: moved_to_failed.__setitem__("called", True),
)
# Error POSTERIOR a la entrega: mover a Procesados falla.
def boom(self, job):
raise OSError("disco lleno al mover a Procesados")
monkeypatch.setattr(RestoreWorker, "_move_zip_to_processed", boom)
worker = RestoreWorker("job-1", base_config)
worker.run()
assert JobStatus.COMPLETED in statuses
assert JobStatus.FAILED not in statuses
assert reported == ["forwarded"]
assert moved_to_failed["called"] is False
def test_fallo_real_de_sftp_va_a_fallados_y_limpia_parcial(monkeypatch, base_config):
"""Fallo genuino de entrega => FAILED, ZIP a Fallados, y limpieza de partes subidas."""
from app.transfer.sftp_copy import SFTPCopyError
statuses: list[str] = []
reported: list[str] = []
_wire_common(monkeypatch, statuses, reported)
# El SFTP falla en la parte 2, adjuntando lo ya subido (envío parcial).
err = SFTPCopyError("timeout en la parte 2")
err.uploaded = ["D:/In/NODO.ZIP.001"]
def failing_upload(*a, **k):
raise err
monkeypatch.setattr(
"app.engine.restore_worker.sftp_copy.upload_zip_parts", failing_upload
)
cleaned: list[str] = []
monkeypatch.setattr(
"app.engine.restore_worker.sftp_copy.cleanup_remote",
lambda target, path: cleaned.append(path),
)
moved_to_failed = {"called": False}
monkeypatch.setattr(
RestoreWorker,
"_move_zip_to_failed",
lambda self, job: moved_to_failed.__setitem__("called", True),
)
worker = RestoreWorker("job-1", base_config)
worker.run()
assert JobStatus.FAILED in statuses
assert JobStatus.COMPLETED not in statuses
assert reported == ["failed"]
assert moved_to_failed["called"] is True
assert cleaned == ["D:/In/NODO.ZIP.001"]

View File

@@ -0,0 +1,89 @@
"""
Pruebas del programador de mantenimiento diario: gating "máximo 1/día", catch-up tras
reinicios y respeto de run_at_hour. Se mockea ConfigRepository con un dict en memoria.
"""
from datetime import datetime
import pytest
from app.engine import maintenance_scheduler as msched
from app.engine.maintenance_scheduler import DailyMaintenanceScheduler
@pytest.fixture
def state_store(monkeypatch):
store: dict = {}
monkeypatch.setattr(
msched.ConfigRepository, "get", staticmethod(lambda key, default=None: store.get(key, default))
)
monkeypatch.setattr(
msched.ConfigRepository, "set", staticmethod(lambda key, value: store.__setitem__(key, value))
)
monkeypatch.setattr(msched.EventRepository, "create", staticmethod(lambda *a, **k: None))
return store
def _scheduler(runs, clock, run_at_hour=3):
return DailyMaintenanceScheduler(
task=lambda: runs.append(1),
run_at_hour=run_at_hour,
clock=clock,
)
def test_corre_una_vez_por_dia(state_store):
runs: list[int] = []
now = {"dt": datetime(2026, 7, 24, 5, 0, 0)}
sched = _scheduler(runs, lambda: now["dt"])
sched._run_if_due() # primer día: corre
sched._run_if_due() # mismo día: NO corre
assert len(runs) == 1
def test_catch_up_al_cambiar_de_dia(state_store):
runs: list[int] = []
now = {"dt": datetime(2026, 7, 24, 5, 0, 0)}
sched = _scheduler(runs, lambda: now["dt"])
sched._run_if_due()
assert len(runs) == 1
now["dt"] = datetime(2026, 7, 25, 5, 0, 0) # día nuevo
sched._run_if_due()
assert len(runs) == 2
def test_respeta_run_at_hour(state_store):
runs: list[int] = []
now = {"dt": datetime(2026, 7, 24, 1, 0, 0)} # antes de las 3
sched = _scheduler(runs, lambda: now["dt"], run_at_hour=3)
sched._run_if_due() # aún no es la hora
assert len(runs) == 0
now["dt"] = datetime(2026, 7, 24, 3, 30, 0) # ya pasó la hora
sched._run_if_due()
assert len(runs) == 1
def test_run_at_hour_none_corre_al_primer_wake(state_store):
runs: list[int] = []
now = {"dt": datetime(2026, 7, 24, 0, 5, 0)}
sched = _scheduler(runs, lambda: now["dt"], run_at_hour=None)
sched._run_if_due()
assert len(runs) == 1
def test_claim_al_inicio_persiste_fecha_aunque_falle(state_store):
"""Si la tarea falla, el turno del día igual se consume (claim al inicio)."""
now = {"dt": datetime(2026, 7, 24, 5, 0, 0)}
def boom():
raise RuntimeError("fallo de limpieza")
sched = DailyMaintenanceScheduler(task=boom, run_at_hour=3, clock=lambda: now["dt"])
sched._run_if_due() # no debe propagar la excepción
assert state_store["retention_last_run"]["last_run_date"] == "2026-07-24"
assert state_store["retention_last_run"]["last_status"].startswith("error")

View File

@@ -0,0 +1,116 @@
"""
Recolección de partes de un ZIP multipart, sin importar la caja de la extensión.
En este dominio los respaldos llegan con extensión en MAYÚSCULAS con frecuencia (los propios
tests del panel usan `GENERICA-TEST.ZIP`). El bug que esto fija: `_collect_zip_paths` hacía
`path.stem.split(".zip")[0]`, que con `EMPRESA.ZIP.001` dejaba `base_name="EMPRESA.ZIP"` y
armaba el glob `EMPRESA.ZIP.zip.*`, que no encuentra nada. Devolvía lista vacía, y como
`_move_zip_to_processed` y `_move_zip_to_failed` iteran sobre ese resultado, **las partes nunca
salían de Entrada**: se acumulaban ahí mezcladas con los pendientes.
"""
from pathlib import Path
import pytest
class _FakeExtractor:
"""Espeja is_multipart de SevenZipExtractor: reconoce .zip.NNN sin importar la caja."""
@staticmethod
def is_multipart(zip_path: str) -> bool:
path = Path(zip_path)
# .zip.001 -> suffix ".001", stem "algo.zip"
return path.stem.lower().endswith(".zip") and len(path.suffix) == 4
def _collect(source_path: str) -> list[str]:
"""
Copia de la lógica de RestoreWorker._collect_zip_paths, aislada para poder probarla sin
arrastrar PySide6 ni pyodbc. Si la implementación cambia, este test debe cambiar con ella.
"""
path = Path(source_path)
if not _FakeExtractor.is_multipart(str(path)):
return [str(path)]
stem = path.stem
base_name = stem[:-4] if stem.lower().endswith(".zip") else stem
prefix = f"{base_name}.zip.".lower()
parts = sorted(
(item for item in path.parent.iterdir() if item.name.lower().startswith(prefix)),
key=lambda item: item.name.lower(),
)
if parts:
return [str(item) for item in parts]
return [str(path)]
@pytest.mark.parametrize("ext", ["zip", "ZIP", "Zip"])
def test_recolecta_todas_las_partes_sin_importar_la_caja(tmp_path: Path, ext: str):
"""Las tres partes se recolectan igual con la extensión en minúsculas, MAYÚSCULAS o mixta."""
names = [f"EMPRESA.{ext}.001", f"EMPRESA.{ext}.002", f"EMPRESA.{ext}.003"]
for name in names:
(tmp_path / name).write_bytes(b"x")
collected = _collect(str(tmp_path / names[0]))
assert [Path(p).name for p in collected] == names, (
f"con extensión .{ext} se recolectaron {len(collected)} de {len(names)} partes"
)
def test_orden_estable_entre_partes(tmp_path: Path):
"""El orden importa: 7-Zip necesita la .001 primero para reensamblar."""
for i in (3, 1, 10, 2):
(tmp_path / f"BASE.ZIP.{i:03d}").write_bytes(b"x")
collected = [Path(p).name for p in _collect(str(tmp_path / "BASE.ZIP.001"))]
assert collected == ["BASE.ZIP.001", "BASE.ZIP.002", "BASE.ZIP.003", "BASE.ZIP.010"]
def test_no_mezcla_partes_de_otro_respaldo(tmp_path: Path):
"""Dos multipart en la misma carpeta no deben contaminarse entre sí."""
for name in ["ALFA.ZIP.001", "ALFA.ZIP.002", "OMEGA.ZIP.001", "OMEGA.zip.002"]:
(tmp_path / name).write_bytes(b"x")
alfa = [Path(p).name for p in _collect(str(tmp_path / "ALFA.ZIP.001"))]
assert alfa == ["ALFA.ZIP.001", "ALFA.ZIP.002"]
# OMEGA tiene sus dos partes con distinta caja: aun así deben salir las dos.
omega = [Path(p).name for p in _collect(str(tmp_path / "OMEGA.ZIP.001"))]
assert sorted(omega) == ["OMEGA.ZIP.001", "OMEGA.zip.002"]
def test_zip_simple_devuelve_solo_ese_archivo(tmp_path: Path):
simple = tmp_path / "UNICO.ZIP"
simple.write_bytes(b"x")
assert _collect(str(simple)) == [str(simple)]
def test_multipart_sin_partes_localizadas_devuelve_el_original(tmp_path: Path):
"""
Si no se localizan las partes, se devuelve el archivo original en lugar de lista vacía:
mover una sola parte es mejor que dejarla atorada en Entrada indefinidamente.
"""
huerfana = tmp_path / "SOLA.ZIP.007"
huerfana.write_bytes(b"x")
# Es multipart por el nombre, y su propia parte sí se encuentra.
assert _collect(str(huerfana)) == [str(huerfana)]
def test_la_implementacion_real_no_usa_glob_en_minusculas():
"""
Tripwire sobre el código real: `glob` distingue mayúsculas en Linux, así que un patrón
en minúsculas nunca encontraría `.ZIP.001`. La implementación debe filtrar iterdir()
comparando en minúsculas.
"""
source = (
Path(__file__).resolve().parent.parent / "app" / "engine" / "restore_worker.py"
).read_text(encoding="utf-8")
start = source.index("def _collect_zip_paths")
body = source[start : start + 2000]
assert 'glob(f"{base_name}.zip.*")' not in body, "glob en minúsculas: no halla .ZIP en Linux"
assert "iterdir()" in body
assert ".lower()" in body

View File

@@ -57,7 +57,9 @@ def test_target_db_vacio_devuelve_none():
def test_target_ok(monkeypatch):
captured = {}
def fake_get(url, headers=None, timeout=None):
# **kwargs porque panel_client también pasa verify=; una firma rígida rompe la prueba
# cada vez que se agrega un kwarg al cliente.
def fake_get(url, headers=None, timeout=None, **kwargs):
captured["url"] = url
return FakeResponse(200, VALID_TARGET)
@@ -116,7 +118,7 @@ def test_target_json_invalido_devuelve_none(monkeypatch):
def test_report_job_result_201_true(monkeypatch):
captured = {}
def fake_post(url, json=None, headers=None, timeout=None):
def fake_post(url, json=None, headers=None, timeout=None, **kwargs):
captured["json"] = json
return FakeResponse(201)
@@ -166,7 +168,7 @@ def test_test_connection_url_invalida():
def test_report_instance_config_200_true(monkeypatch):
captured = {}
def fake_post(url, json=None, headers=None, timeout=None):
def fake_post(url, json=None, headers=None, timeout=None, **kwargs):
captured["url"] = url
captured["json"] = json
return FakeResponse(200)
@@ -256,7 +258,7 @@ CATALOG_RESPONSE = {
def test_list_restore_target_names_ok(monkeypatch):
captured = {}
def fake_get(url, headers=None, timeout=None):
def fake_get(url, headers=None, timeout=None, **kwargs):
captured["url"] = url
return FakeResponse(200, CATALOG_RESPONSE)
@@ -346,7 +348,7 @@ ROUTE_RESTORE_LOCAL = {
def test_resolve_route_forward_ok(monkeypatch):
captured = {}
def fake_get(url, headers=None, timeout=None):
def fake_get(url, headers=None, timeout=None, **kwargs):
captured["url"] = url
return FakeResponse(200, ROUTE_FORWARD)
@@ -379,3 +381,71 @@ def test_resolve_route_forward_sin_input_folder_invalido(monkeypatch):
panel_client.requests, "get", lambda *a, **k: FakeResponse(200, bad)
)
assert panel_client.resolve_route(URL, TOKEN, "X.ZIP") is None
# ============================================================================
# platform / arch: el PANEL los usa para elegir qué artefacto le toca a este
# servidor al instalar o actualizar (a24c.cras_releases se llavea por
# version + platform + arch).
# ============================================================================
def _capture_post(monkeypatch) -> dict:
captured = {}
def fake_post(url, json=None, headers=None, timeout=None, **kwargs):
captured["url"] = url
captured["json"] = json
captured["headers"] = headers
return FakeResponse(200)
monkeypatch.setattr(panel_client.requests, "post", fake_post)
return captured
def test_report_instance_config_envia_platform_y_arch(monkeypatch):
captured = _capture_post(monkeypatch)
ok = panel_client.report_instance_config(
URL,
TOKEN,
r"D:\Backups\Entrada",
processed_folder=r"D:\Backups\Procesados",
host_name="WIN-01",
app_version="1.1.0",
instance_key="Alfa",
platform_name="windows",
arch="x86_64",
)
assert ok is True
assert captured["json"]["platform"] == "windows"
assert captured["json"]["arch"] == "x86_64"
assert captured["json"]["processed_folder"] == r"D:\Backups\Procesados"
def test_report_instance_config_sin_platform_manda_none(monkeypatch):
# Compatibilidad hacia atrás: un agente viejo no manda estas claves y el PANEL
# debe poder caer al texto libre de restore_targets.os.
captured = _capture_post(monkeypatch)
panel_client.report_instance_config(URL, TOKEN, r"D:\In")
assert captured["json"]["platform"] is None
assert captured["json"]["arch"] is None
@pytest.mark.parametrize("blank", ["", " ", None])
def test_report_instance_config_platform_en_blanco_es_none(monkeypatch, blank):
captured = _capture_post(monkeypatch)
panel_client.report_instance_config(
URL, TOKEN, r"D:\In", platform_name=blank, arch=blank
)
assert captured["json"]["platform"] is None
assert captured["json"]["arch"] is None
def test_constantes_platform_arch_son_del_vocabulario_del_panel():
# El PANEL valida platform contra ('windows','linux'); si esto cambia hay que
# actualizar el CHECK de a24c.cras_releases y la validación del endpoint.
from app.constants import APP_ARCH, APP_PLATFORM
assert APP_PLATFORM in ("windows", "linux")
assert APP_ARCH and APP_ARCH == APP_ARCH.strip()
assert " " not in APP_ARCH

View File

@@ -0,0 +1,291 @@
"""
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)

157
tests/test_retention.py Normal file
View File

@@ -0,0 +1,157 @@
"""
Pruebas de la retención por nodo (Procesados/) y por antigüedad (Fallados/).
Se mockea el acceso a la BD (JobRepository/EventRepository) y se opera sobre carpetas reales
en tmp_path. Las fechas-carpeta de Procesados/ se derivan con el mismo helper que usa el
limpiador para que la prueba sea independiente de la zona horaria del runner.
"""
from datetime import datetime
import pytest
from app.engine.retention import RetentionCleaner
class FakeJob:
def __init__(self, job_id, node_name, source_name, finished_at):
self.job_id = job_id
self.node_name = node_name
self.source_name = source_name
self.finished_at = finished_at
def _config(tmp_path, *, days=2, failed_days=7, dry_run=False):
return {
"paths": {
"processed_folder": str(tmp_path / "processed"),
"failed_folder": str(tmp_path / "failed"),
},
"retention": {"days": days, "failed_days": failed_days, "dry_run": dry_run},
}
def _date_folder(base, iso):
"""Crea (si falta) la carpeta-fecha local correspondiente a un finished_at ISO UTC."""
local_date = RetentionCleaner._local_date_from_iso(iso)
folder = base / local_date.isoformat()
folder.mkdir(parents=True, exist_ok=True)
return folder
def test_procesados_borra_obsoletos_conserva_reciente_y_nodo_unico(monkeypatch, tmp_path):
processed = tmp_path / "processed"
ref_iso = "2026-07-24T10:00:00"
obsolete_iso = "2026-07-20T10:00:00"
node_b_iso = "2026-07-22T10:00:00"
# NODO_A: copia obsoleta (a borrar) + copia más reciente (a conservar).
obsolete_file = _date_folder(processed, obsolete_iso) / "NODO_A.zip"
obsolete_file.write_bytes(b"viejo")
recent_file = _date_folder(processed, ref_iso) / "NODO_A.zip"
recent_file.write_bytes(b"nuevo")
# NODO_B: una sola restauración (nunca se toca).
node_b_file = _date_folder(processed, node_b_iso) / "NODO_B.zip"
node_b_file.write_bytes(b"unico")
obsolete_job = FakeJob("job-a-old", "NODO_A", "NODO_A.zip", obsolete_iso)
purged: list[str] = []
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_obsolete_completed_by_node",
lambda days: [obsolete_job],
)
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_latest_completed_per_node",
lambda: {"NODO_A": ref_iso, "NODO_B": node_b_iso},
)
monkeypatch.setattr(
"app.engine.retention.JobRepository.mark_purged", lambda job_id: purged.append(job_id)
)
monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None)
result = RetentionCleaner(_config(tmp_path)).run()
assert not obsolete_file.exists() # obsoleto borrado
assert recent_file.exists() # más reciente intacto
assert node_b_file.exists() # nodo de una sola copia intacto
assert purged == ["job-a-old"]
assert result.deleted_files == 1
def test_procesados_dry_run_no_borra(monkeypatch, tmp_path):
processed = tmp_path / "processed"
obsolete_iso = "2026-07-20T10:00:00"
obsolete_file = _date_folder(processed, obsolete_iso) / "NODO_A.zip"
obsolete_file.write_bytes(b"viejo")
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_obsolete_completed_by_node",
lambda days: [FakeJob("job-a-old", "NODO_A", "NODO_A.zip", obsolete_iso)],
)
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_latest_completed_per_node",
lambda: {"NODO_A": "2026-07-24T10:00:00"},
)
purged: list[str] = []
monkeypatch.setattr(
"app.engine.retention.JobRepository.mark_purged", lambda job_id: purged.append(job_id)
)
monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None)
result = RetentionCleaner(_config(tmp_path, dry_run=True)).run()
assert obsolete_file.exists() # dry-run no borra
assert purged == [] # ni marca purgado
assert result.dry_run is True
assert result.deleted_files == 1 # sí lo contabiliza como "se borraría"
def test_fallados_borra_por_antiguedad_absoluta(monkeypatch, tmp_path):
failed = tmp_path / "failed"
old_folder = failed / "2026-07-10" # < (hoy - 7)
recent_folder = failed / "2026-07-20" # >= (hoy - 7)
old_folder.mkdir(parents=True)
recent_folder.mkdir(parents=True)
old_file = old_folder / "VIEJO.zip"
old_file.write_bytes(b"x")
recent_file = recent_folder / "RECIENTE.zip"
recent_file.write_bytes(b"y")
# Sin obsoletos en Procesados; solo probamos Fallados.
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_obsolete_completed_by_node", lambda days: []
)
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_latest_completed_per_node", lambda: {}
)
monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None)
cleaner = RetentionCleaner(
_config(tmp_path, failed_days=7), clock=lambda: datetime(2026, 7, 24, 12, 0, 0)
)
cleaner.run()
assert not old_file.exists() # carpeta-fecha vieja borrada
assert recent_file.exists() # dentro de la ventana, se conserva
def test_fallados_ignora_carpetas_no_fecha(monkeypatch, tmp_path):
failed = tmp_path / "failed"
weird = failed / "no-es-fecha"
weird.mkdir(parents=True)
keep = weird / "algo.zip"
keep.write_bytes(b"z")
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_obsolete_completed_by_node", lambda days: []
)
monkeypatch.setattr(
"app.engine.retention.JobRepository.get_latest_completed_per_node", lambda: {}
)
monkeypatch.setattr("app.engine.retention.EventRepository.create", lambda *a, **k: None)
RetentionCleaner(
_config(tmp_path), clock=lambda: datetime(2026, 7, 24, 12, 0, 0)
).run()
assert keep.exists() # nombre que no es fecha: no se toca

View File

@@ -2,6 +2,8 @@
Pruebas de la transferencia SFTP al servidor remoto. Se mockea paramiko para no
requerir un servidor SSH real; se valida la conversión de rutas y el flujo de subida.
"""
import os
import pytest
from app.transfer import sftp_copy
@@ -32,12 +34,26 @@ def test_upload_origen_inexistente(tmp_path):
sftp_copy.upload_to_remote(str(tmp_path / "noexiste.bak"), CFG)
class FakeStat:
def __init__(self, st_size):
self.st_size = st_size
class FakeSFTP:
def __init__(self, store):
self.store = store
def put(self, local, remote):
def put(self, local, remote, confirm=True):
self.store["put"] = (local, remote)
self.store["confirm"] = confirm
# Registra el tamaño para que stat() (verificación de subida) lo confirme.
self.store.setdefault("sizes", {})[remote] = os.path.getsize(local)
def stat(self, remote):
sizes = self.store.get("sizes", {})
if remote not in sizes:
raise FileNotFoundError(remote)
return FakeStat(sizes[remote])
def remove(self, remote):
self.store["removed"] = remote
@@ -133,3 +149,65 @@ def test_upload_file_to_folder_vacio_falla(tmp_path):
f.write_bytes(b"x")
with pytest.raises(SFTPCopyError, match="carpeta remota"):
sftp_copy.upload_file_to_folder(str(f), CFG, " ")
def test_upload_usa_confirm_false(tmp_path, monkeypatch):
"""La subida no debe delegar la verificación al confirm inmediato de paramiko."""
zf = tmp_path / "backup.zip"
zf.write_bytes(b"zipdata")
store: dict = {}
monkeypatch.setattr(sftp_copy, "paramiko", _fake_paramiko(store))
sftp_copy.upload_file_to_folder(str(zf), CFG, "D:\\In")
assert store["confirm"] is False
def test_verify_remote_size_reintenta_stat_flaky(monkeypatch):
"""Un stat transitoriamente fallido se reintenta y NO produce falso fallo."""
monkeypatch.setattr(sftp_copy, "VERIFY_DELAY_SECONDS", 0)
calls = {"n": 0}
class Flaky:
def stat(self, remote):
calls["n"] += 1
if calls["n"] < 2:
raise OSError("stat flaky")
return FakeStat(100)
sftp_copy._verify_remote_size(Flaky(), "C:/In/x.zip", 100) # no debe lanzar
assert calls["n"] == 2
def test_verify_remote_size_tamano_incorrecto_falla(monkeypatch):
"""Si el tamaño remoto nunca coincide, es un fallo genuino de entrega."""
monkeypatch.setattr(sftp_copy, "VERIFY_DELAY_SECONDS", 0)
class Wrong:
def stat(self, remote):
return FakeStat(50)
with pytest.raises(SFTPCopyError, match="verificar"):
sftp_copy._verify_remote_size(Wrong(), "C:/In/x.zip", 100)
def test_upload_zip_parts_adjunta_uploaded_en_fallo(tmp_path, monkeypatch):
"""Ante un fallo parcial, la excepción lleva las partes ya subidas para limpieza."""
p1 = tmp_path / "big.zip.001"
p2 = tmp_path / "big.zip.002"
p1.write_bytes(b"a")
p2.write_bytes(b"b")
calls = {"n": 0}
def fake_upload(local, cfg, remote_folder):
calls["n"] += 1
if calls["n"] == 1:
return "D:/In/big.zip.001"
raise SFTPCopyError("boom en la parte 2")
monkeypatch.setattr(sftp_copy, "upload_file_to_folder", fake_upload)
with pytest.raises(SFTPCopyError) as exc_info:
sftp_copy.upload_zip_parts([str(p1), str(p2)], CFG, "D:\\In")
assert getattr(exc_info.value, "uploaded", None) == ["D:/In/big.zip.001"]