1.6.2: manifiesto firmado, tiempo estimado en tareas, pruebas automáticas
- firma.py: RSA-2048 PKCS#1 v1.5 SHA-256 sin dependencias; empaquetar.py firma el manifiesto y actualizador.py exige la firma (CLAVE_PUBLICA incrustada). - Barra de tareas: tiempo restante estimado a partir del avance. - Corregido: APLICANDO nunca llegaba al total y «Moviendo» quedaba fijo, bloqueando desconectar la carpeta. - probar.py: reglas, firma y recorrido completo sobre una copia temporal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
@@ -3,7 +3,7 @@ from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
datas = []
|
||||
binaries = []
|
||||
hiddenimports = ['resolve_bridge', 'reglas', 'asistente', 'actualizador']
|
||||
hiddenimports = ['resolve_bridge', 'reglas', 'asistente', 'actualizador', 'firma']
|
||||
tmp_ret = collect_all('webview')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('clr_loader')
|
||||
|
||||
11
README.md
11
README.md
@@ -264,6 +264,17 @@ a soltar la carpeta el escaneo anterior sigue ahí). No deja desconectar mientra
|
||||
se analiza, se le aplica el plan o tiene conversiones pendientes. Endpoint `POST /api/raiz/quitar
|
||||
{raiz, datos}`; queda anotado en la bitácora como `raiz_quitada`.
|
||||
|
||||
1.6.2: el manifiesto de actualización va firmado. `firma.py` (RSA-2048 sin dependencias) firma
|
||||
versión+archivo+sha256+tamaño al empaquetar con la clave privada de `%USERPROFILE%\.labstream\organizador-firma.json`
|
||||
(haz copia de seguridad: sin ella no se pueden publicar versiones nuevas); la pública está incrustada en
|
||||
`actualizador.py` y el .exe no instala nada que no cuadre con ella, aunque alguien cambiara el archivo en el
|
||||
servidor. Los .exe anteriores (≤ 1.6.1) ignoran la firma y siguen actualizándose. La barra de tareas muestra el
|
||||
tiempo restante estimado (≈ 4 min) a partir del avance. Corregido: tras aplicar el plan, «Moviendo» se quedaba
|
||||
en la barra de tareas para siempre y bloqueaba desconectar la carpeta. Pruebas automáticas en `probar.py`
|
||||
(reglas, firma, y un recorrido completo escanear → plan → aplicar → deshacer → desconectar sobre una copia
|
||||
temporal): `py -3.12 probar.py`. Rendimiento medido sobre el inventario real (54 mil archivos): las consultas
|
||||
con LIKE tardan menos de 10 ms; no hacía falta optimizar.
|
||||
|
||||
Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol
|
||||
exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó
|
||||
con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:
|
||||
|
||||
@@ -13,6 +13,7 @@ Lo que se exige del manifiesto para instalar (si no se cumple, no se instala y s
|
||||
- nombre de archivo simple (sin rutas) terminado en .zip
|
||||
- sha256 de 64 hex, obligatorio
|
||||
- URL de descarga con el mismo esquema y host que el manifiesto (nada de saltar a otro servidor)
|
||||
- firma RSA (firma.py) de versión+archivo+sha256+tamaño que cuadre con CLAVE_PUBLICA; sin ella no se instala
|
||||
|
||||
`O` es el módulo organizador (lo fija él al arrancar).
|
||||
"""
|
||||
@@ -28,6 +29,7 @@ import tempfile
|
||||
import threading
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import firma
|
||||
import urllib.request
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
@@ -55,6 +57,10 @@ def _tupla(v):
|
||||
return tuple(out + [0] * (3 - len(out)))
|
||||
|
||||
|
||||
# clave pública de firma.py (la privada está en ~/.labstream/organizador-firma.json, solo la usa empaquetar.py)
|
||||
CLAVE_PUBLICA = {"e": 65537, "n": 24937087096150856258549531047458881410156319770314378325709810834727102787144078753904837107336360100277476122470417827694103702029600632424402930901393600571590411504459673905624547394933513534401412673838815719634257887193733377640612491708305947101493071063533297310409777772800911393696346540036769491297235656643827279260544972034028016485933509783117840560074417501599224256143123720254264555615803987537224119534643367425486525306755766385726396897033934795259582227391903282650010470084464660818450797096588538639818043343189296266160938215570507865913766129394056803324311835125960516203968300455854004216307}
|
||||
|
||||
|
||||
def _validar(m, url_manifiesto):
|
||||
"""Devuelve (dict disponible, "") o (None, motivo). Solo acepta manifiestos bien formados."""
|
||||
app = m.get("app") or {}
|
||||
@@ -73,6 +79,9 @@ def _validar(m, url_manifiesto):
|
||||
return None, f"la descarga apunta a otro servidor ({b.netloc}); solo se acepta {a.netloc}"
|
||||
if a.scheme != "https" and not a.netloc.split(":")[0] in ("127.0.0.1", "localhost"):
|
||||
return None, "el manifiesto debe servirse por https"
|
||||
f = m.get("firma") or {}
|
||||
if not isinstance(f, dict) or not firma.verificar(firma.texto_manifiesto(m), str(f.get("valor") or ""), CLAVE_PUBLICA):
|
||||
return None, "el manifiesto no está firmado con la clave de Labstream (o se modificó); no se instala nada"
|
||||
return {"version": version, "notes": str(m.get("notes", ""))[:2000], "archivo": archivo,
|
||||
"size": int(app.get("size") or 0), "sha256": sha.lower(), "url": url}, ""
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ for d in ("build", "dist"):
|
||||
subprocess.check_call([sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onedir", "--windowed",
|
||||
"--name", "OrganizadorLabstream", "--icon", os.path.join("static", "organilab.ico"),
|
||||
"--hidden-import", "resolve_bridge", "--hidden-import", "reglas",
|
||||
"--hidden-import", "asistente", "--hidden-import", "actualizador",
|
||||
"--hidden-import", "asistente", "--hidden-import", "actualizador", "--hidden-import", "firma",
|
||||
"--collect-all", "webview", "--collect-all", "clr_loader", "--collect-all", "pythonnet",
|
||||
"--exclude-module", "tkinter", "organizador.py"])
|
||||
dest = os.path.join("dist", "OrganizadorLabstream")
|
||||
@@ -118,6 +118,18 @@ manifiesto = {
|
||||
"app": {"archivo": app_zip, "size": os.path.getsize(app_zip), "sha256": sha(app_zip), "url": f"/api/herramientas/{app_zip}"},
|
||||
"notes": notas,
|
||||
}
|
||||
import firma
|
||||
if os.environ.get("FIRMA") == "no":
|
||||
print("AVISO: manifiesto sin firmar (FIRMA=no); los .exe 1.6.2+ no lo aceptarán")
|
||||
else:
|
||||
if not os.path.exists(firma.RUTA_PRIVADA):
|
||||
sys.exit(f"falta la clave de firma en {firma.RUTA_PRIVADA}: corre py -3.12 firma.py generar (o FIRMA=no para saltarlo)")
|
||||
_priv = firma.cargar_privada()
|
||||
manifiesto["firma"] = {"alg": "rsa2048-pkcs1v15-sha256", "valor": firma.firmar(firma.texto_manifiesto(manifiesto), _priv)}
|
||||
import actualizador
|
||||
if not firma.verificar(firma.texto_manifiesto(manifiesto), manifiesto["firma"]["valor"], actualizador.CLAVE_PUBLICA):
|
||||
sys.exit("la clave privada no corresponde a CLAVE_PUBLICA de actualizador.py; el manifiesto no serviría")
|
||||
print("manifiesto firmado")
|
||||
ruta_os = os.path.join(os.path.dirname(AQUI), "labstream-os", "public", "herramientas", "organizador.json")
|
||||
if os.path.isdir(os.path.dirname(ruta_os)):
|
||||
previo = {}
|
||||
|
||||
134
firma.py
Archivo normal
134
firma.py
Archivo normal
@@ -0,0 +1,134 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
Firma del manifiesto de actualización (RSA-2048, PKCS#1 v1.5, SHA-256) sin dependencias.
|
||||
|
||||
La clave privada vive fuera del proyecto, en %USERPROFILE%\.labstream\organizador-firma.json, y solo la
|
||||
usa empaquetar.py al publicar. La clave pública va incrustada en actualizador.py: un .exe instalado solo
|
||||
acepta manifiestos firmados con ella, así que aunque alguien pudiera cambiar el archivo en el servidor,
|
||||
no podría hacer que la app instale un paquete que no salió de aquí.
|
||||
|
||||
py -3.12 firma.py generar crea la clave (si no existe) y muestra la pública para pegarla
|
||||
py -3.12 firma.py probar firma y verifica un texto de prueba
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
RUTA_PRIVADA = os.path.join(os.path.expanduser("~"), ".labstream", "organizador-firma.json")
|
||||
BITS = 2048
|
||||
E = 65537
|
||||
# DigestInfo de SHA-256 (RFC 8017, apéndice B.1)
|
||||
_DI_SHA256 = bytes.fromhex("3031300d060960864801650304020105000420")
|
||||
|
||||
|
||||
def _es_primo(n, rondas=40):
|
||||
if n < 2:
|
||||
return False
|
||||
for p in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37):
|
||||
if n % p == 0:
|
||||
return n == p
|
||||
d, r = n - 1, 0
|
||||
while d % 2 == 0:
|
||||
d //= 2
|
||||
r += 1
|
||||
for _ in range(rondas):
|
||||
a = secrets.randbelow(n - 3) + 2
|
||||
x = pow(a, d, n)
|
||||
if x in (1, n - 1):
|
||||
continue
|
||||
for _ in range(r - 1):
|
||||
x = pow(x, 2, n)
|
||||
if x == n - 1:
|
||||
break
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _primo(bits):
|
||||
while True:
|
||||
c = secrets.randbits(bits) | (1 << (bits - 1)) | 1
|
||||
if _es_primo(c):
|
||||
return c
|
||||
|
||||
|
||||
def generar(ruta=RUTA_PRIVADA):
|
||||
"""Crea la pareja de claves y guarda la privada en `ruta`. Devuelve la pública {n, e}."""
|
||||
if os.path.exists(ruta):
|
||||
raise FileExistsError(f"ya hay una clave en {ruta}; no se sobrescribe")
|
||||
while True:
|
||||
p, q = _primo(BITS // 2), _primo(BITS // 2)
|
||||
n = p * q
|
||||
phi = (p - 1) * (q - 1)
|
||||
if p != q and n.bit_length() == BITS and phi % E:
|
||||
break
|
||||
d = pow(E, -1, phi)
|
||||
os.makedirs(os.path.dirname(ruta), exist_ok=True)
|
||||
with open(ruta, "w", encoding="utf-8") as fh:
|
||||
json.dump({"n": n, "e": E, "d": d}, fh)
|
||||
try:
|
||||
os.chmod(ruta, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return {"n": n, "e": E}
|
||||
|
||||
|
||||
def cargar_privada(ruta=RUTA_PRIVADA):
|
||||
with open(ruta, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _em(mensaje, k):
|
||||
"""EMSA-PKCS1-v1_5: 00 01 FF..FF 00 DigestInfo || hash"""
|
||||
t = _DI_SHA256 + hashlib.sha256(mensaje).digest()
|
||||
return b"\x00\x01" + b"\xff" * (k - len(t) - 3) + b"\x00" + t
|
||||
|
||||
|
||||
def firmar(mensaje, priv):
|
||||
"""Devuelve la firma en hex del mensaje (bytes)."""
|
||||
k = (priv["n"].bit_length() + 7) // 8
|
||||
m = int.from_bytes(_em(mensaje, k), "big")
|
||||
return pow(m, priv["d"], priv["n"]).to_bytes(k, "big").hex()
|
||||
|
||||
|
||||
def verificar(mensaje, firma_hex, pub):
|
||||
"""True si la firma corresponde al mensaje y a la clave pública {n, e}."""
|
||||
try:
|
||||
k = (pub["n"].bit_length() + 7) // 8
|
||||
s = bytes.fromhex(firma_hex)
|
||||
if len(s) != k:
|
||||
return False
|
||||
m = pow(int.from_bytes(s, "big"), pub["e"], pub["n"]).to_bytes(k, "big")
|
||||
return secrets.compare_digest(m, _em(mensaje, k))
|
||||
except Exception: # noqa
|
||||
return False
|
||||
|
||||
|
||||
def texto_manifiesto(m):
|
||||
"""Lo que se firma: los datos del manifiesto que deciden qué se instala. Cambiar cualquiera invalida la firma."""
|
||||
app = m.get("app") or {}
|
||||
return "\n".join(["organilab-manifiesto-v1", str(m.get("version", "")), str(app.get("archivo", "")),
|
||||
str(app.get("sha256", "")).lower(), str(app.get("size", ""))]).encode("utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
orden = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
if orden == "generar":
|
||||
if os.path.exists(RUTA_PRIVADA):
|
||||
pub = {k: cargar_privada()[k] for k in ("n", "e")}
|
||||
print("ya existía la clave en", RUTA_PRIVADA)
|
||||
else:
|
||||
print("generando clave RSA-2048 (unos segundos)…")
|
||||
pub = generar()
|
||||
print("clave privada guardada en", RUTA_PRIVADA, "— haz copia de seguridad: sin ella no se pueden publicar versiones nuevas")
|
||||
print("\nCLAVE_PUBLICA para actualizador.py:")
|
||||
print(f'CLAVE_PUBLICA = {{"e": {pub["e"]}, "n": {pub["n"]}}}')
|
||||
elif orden == "probar":
|
||||
priv = cargar_privada()
|
||||
pub = {"n": priv["n"], "e": priv["e"]}
|
||||
f = firmar(b"hola", priv)
|
||||
print("firma ok:", verificar(b"hola", f, pub), "· alterada:", verificar(b"holA", f, pub))
|
||||
else:
|
||||
print(__doc__)
|
||||
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"nombre": "OrganiLAB",
|
||||
"version": "1.6.1",
|
||||
"builtAt": "2026-09-15T19:21:15.000Z",
|
||||
"archivo": "OrganizadorLabstream-portatil-1.6.1.zip",
|
||||
"size": 198619195,
|
||||
"sha256": "913cd3f1a7b04b38606b8b42c6cb6bc1b495b873771212ccbfa407b4fad60d14",
|
||||
"version": "1.6.2",
|
||||
"builtAt": "2026-09-15T19:46:00.000Z",
|
||||
"archivo": "OrganizadorLabstream-portatil-1.6.2.zip",
|
||||
"size": 198625969,
|
||||
"sha256": "48ef016966c45d53f6d1d934a6e7bf735c0b20e46413c487ffdd2b4b04b3ef89",
|
||||
"app": {
|
||||
"archivo": "OrganizadorLabstream-app-1.6.1.zip",
|
||||
"size": 14799458,
|
||||
"sha256": "c722527fb16d6e139d82a268fc6fadf79a14f48eb0a134ac2240086c24f18487",
|
||||
"url": "/api/herramientas/OrganizadorLabstream-app-1.6.1.zip"
|
||||
"archivo": "OrganizadorLabstream-app-1.6.2.zip",
|
||||
"size": 14806232,
|
||||
"sha256": "f45a61a47ffd678f76e6e184fefff6f2cceb860a6b82f8d305b823cebbf2b853",
|
||||
"url": "/api/herramientas/OrganizadorLabstream-app-1.6.2.zip"
|
||||
},
|
||||
"notes": ""
|
||||
"notes": "",
|
||||
"firma": {
|
||||
"alg": "rsa2048-pkcs1v15-sha256",
|
||||
"valor": "013a3ac0886f655e22e2a30913be8a54880e43ec5663dcda8537a220a014438998533b1d440b84e33e7393e8d544089c215d7af3acb27390471ffa728da1d4f452cffad7534c51041f40695152431086b165348219fba51de93b1ae0403a3b748fc1e0022a707797868825d934d21d676debc6cfa8b54d6e7f4b0bd5c83b6ed6a9fe1f60bcee5cc9e5aa06710e120c2bbcdbcd59356e9931aed4c3206815b8c99746940be562a196144c9e22c4e0aa17ea43852c79a9b6e23076ff18c89c76971946aed10e05ca29aaedc95f3e0d39ffa71f020125d7bf272d9a5f9ef8b5f6b6e5cbb4930e12b4c598b6343bd779c1d0ead18aad70eaa442badc303ad22bbca3"
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import actualizador
|
||||
asistente.O = sys.modules[__name__]
|
||||
actualizador.O = sys.modules[__name__]
|
||||
|
||||
VERSION = "1.6.1"
|
||||
VERSION = "1.6.2"
|
||||
|
||||
CONGELADO = getattr(sys, "frozen", False)
|
||||
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller
|
||||
@@ -967,6 +967,7 @@ def aplicar(raiz, filas):
|
||||
hechas += 1
|
||||
except Exception as ex: # noqa
|
||||
errores.append(f"{f.get('origen_rel')}: {ex}")
|
||||
APLICANDO.update(hechas=len(filas), actual="") # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo»
|
||||
# carpetas que quedaron vacías al vaciarlas (p. ej. "Grabacion" tras repartir sus archivos)
|
||||
for f in filas:
|
||||
base = f["origen"] if f.get("archivos") else os.path.dirname(f["origen"])
|
||||
|
||||
216
probar.py
Archivo normal
216
probar.py
Archivo normal
@@ -0,0 +1,216 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
Pruebas automáticas de OrganiLAB. Se corren con: py -3.12 probar.py
|
||||
|
||||
- reglas: detección de cámara, mes, año, basura y proxies
|
||||
- firma: firmar/verificar y rechazo de manifiestos alterados o sin firmar
|
||||
- servidor (extremo a extremo): copia el programa a una carpeta temporal, lo arranca en un puerto libre,
|
||||
registra una carpeta de ensayo, la escanea, calcula el plan, lo aplica, lo deshace, y la desconecta.
|
||||
No toca datos\ ni el inventario real: todo ocurre en la carpeta temporal.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import shutil
|
||||
import socket
|
||||
import tempfile
|
||||
import unittest
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, AQUI)
|
||||
import reglas as R # noqa: E402
|
||||
import firma # noqa: E402
|
||||
|
||||
|
||||
class Reglas(unittest.TestCase):
|
||||
def test_firma_camara(self):
|
||||
self.assertEqual(R.firma_archivo("C0001.MP4"), "SONY")
|
||||
self.assertEqual(R.firma_archivo("C0001M01.XML"), "SONY")
|
||||
self.assertEqual(R.firma_archivo("20260301_labstream0001.mp4"), "SONY")
|
||||
self.assertIsNone(R.firma_archivo("informe final.docx"))
|
||||
|
||||
def test_mes(self):
|
||||
self.assertEqual(R.detectar_mes("09- Septiembre"), (9, False))
|
||||
self.assertEqual(R.detectar_mes("02-marzo"), (3, True)) # gana el nombre, con conflicto
|
||||
self.assertEqual(R.detectar_mes("MAYO"), (5, False))
|
||||
self.assertEqual(R.detectar_mes("3"), (3, True)) # número solo: confianza media
|
||||
self.assertEqual(R.detectar_mes("13"), (None, False))
|
||||
self.assertEqual(R.detectar_mes("Brigard"), (None, False))
|
||||
|
||||
def test_anio(self):
|
||||
self.assertEqual(R.detectar_anio("2026"), 2026)
|
||||
self.assertIsNone(R.detectar_anio("2026 - agosto"))
|
||||
|
||||
def test_basura_y_proxies(self):
|
||||
self.assertTrue(R.es_basura_archivo("Thumbs.db"))
|
||||
self.assertTrue(R.es_basura_archivo(".DS_Store"))
|
||||
self.assertFalse(R.es_basura_archivo("C0001.MP4"))
|
||||
self.assertTrue(R.es_proxy_dir("Proxy"))
|
||||
self.assertFalse(R.es_proxy_dir("Entregas"))
|
||||
|
||||
|
||||
class Firma(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.tmp = tempfile.mkdtemp(prefix="organilab-firma-")
|
||||
cls.pub = firma.generar(os.path.join(cls.tmp, "clave.json"))
|
||||
cls.priv = firma.cargar_privada(os.path.join(cls.tmp, "clave.json"))
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
shutil.rmtree(cls.tmp, ignore_errors=True)
|
||||
|
||||
def test_firmar_verificar(self):
|
||||
f = firma.firmar(b"hola", self.priv)
|
||||
self.assertTrue(firma.verificar(b"hola", f, self.pub))
|
||||
self.assertFalse(firma.verificar(b"holA", f, self.pub))
|
||||
self.assertFalse(firma.verificar(b"hola", f[:-2] + "00", self.pub))
|
||||
self.assertFalse(firma.verificar(b"hola", "zz", self.pub))
|
||||
|
||||
def test_manifiesto(self):
|
||||
import actualizador
|
||||
m = {"version": "9.9.9", "app": {"archivo": "OrganizadorLabstream-app-9.9.9.zip", "sha256": "a" * 64, "size": 10, "url": "/api/herramientas/x.zip"}}
|
||||
url = "https://os.labstreamsas.com/herramientas/organizador.json"
|
||||
d, motivo = actualizador._validar(m, url)
|
||||
self.assertIsNone(d)
|
||||
self.assertIn("firmado", motivo)
|
||||
m["firma"] = {"valor": firma.firmar(firma.texto_manifiesto(m), self.priv)}
|
||||
# firmado con OTRA clave (la de prueba): tampoco vale para la clave real
|
||||
self.assertIsNone(actualizador._validar(m, url)[0])
|
||||
viejo = actualizador.CLAVE_PUBLICA
|
||||
actualizador.CLAVE_PUBLICA = self.pub
|
||||
try:
|
||||
self.assertIsNotNone(actualizador._validar(m, url)[0])
|
||||
m["app"]["sha256"] = "b" * 64
|
||||
self.assertIsNone(actualizador._validar(m, url)[0])
|
||||
finally:
|
||||
actualizador.CLAVE_PUBLICA = viejo
|
||||
|
||||
|
||||
def _puerto_libre():
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
p = s.getsockname()[1]
|
||||
s.close()
|
||||
return p
|
||||
|
||||
|
||||
class Servidor(unittest.TestCase):
|
||||
"""Extremo a extremo sobre una copia temporal del programa."""
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.tmp = tempfile.mkdtemp(prefix="organilab-e2e-")
|
||||
cls.app = os.path.join(cls.tmp, "app")
|
||||
os.makedirs(cls.app)
|
||||
for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py"):
|
||||
shutil.copy(os.path.join(AQUI, f), cls.app)
|
||||
shutil.copytree(os.path.join(AQUI, "static"), os.path.join(cls.app, "static"))
|
||||
# carpeta de ensayo: cámara Sony, basura y un proxy
|
||||
cls.raiz = os.path.join(cls.tmp, "Cliente Prueba")
|
||||
for rel, tam in (("Rodaje/C0001.MP4", 3_000_000), ("Rodaje/C0001M01.XML", 500), ("Rodaje/Thumbs.db", 100),
|
||||
("Rodaje/Proxy/C0001.MOV", 200_000), ("Entregas/Video final.mp4", 800_000), ("notas.txt", 50)):
|
||||
p = os.path.join(cls.raiz, rel)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with open(p, "wb") as fh:
|
||||
fh.write(os.urandom(min(tam, 4096)) * max(1, tam // 4096))
|
||||
cls.puerto = _puerto_libre()
|
||||
env = dict(os.environ, ORGANIZADOR_PUERTO=str(cls.puerto), PYTHONIOENCODING="utf-8")
|
||||
cls.log = open(os.path.join(cls.tmp, "servidor.log"), "w", encoding="utf-8")
|
||||
cls.proc = subprocess.Popen([sys.executable, "organizador.py", "--sin-navegador"], cwd=cls.app, env=env, stdout=cls.log, stderr=subprocess.STDOUT)
|
||||
cls.base = f"http://127.0.0.1:{cls.puerto}"
|
||||
for _ in range(60):
|
||||
try:
|
||||
urllib.request.urlopen(cls.base + "/api/estado", timeout=1)
|
||||
break
|
||||
except Exception: # noqa
|
||||
time.sleep(0.25)
|
||||
else:
|
||||
raise RuntimeError("el servidor no arrancó: " + open(cls.log.name, encoding="utf-8", errors="replace").read()[-800:])
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.proc.kill()
|
||||
cls.proc.wait()
|
||||
cls.log.close()
|
||||
shutil.rmtree(cls.tmp, ignore_errors=True)
|
||||
|
||||
def get(self, p):
|
||||
return json.loads(urllib.request.urlopen(self.base + p, timeout=30).read())
|
||||
|
||||
def post(self, p, d):
|
||||
rq = urllib.request.Request(self.base + p, json.dumps(d).encode(), {"Content-Type": "application/json", "Origin": self.base})
|
||||
try:
|
||||
r = urllib.request.urlopen(rq, timeout=60)
|
||||
return r.status, json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read())
|
||||
|
||||
def esperar_escaneo(self):
|
||||
for _ in range(200):
|
||||
r = next((x for x in self.get("/api/estado")["raices"] if x["ruta"].lower() == self.raiz.lower()), None)
|
||||
if r and r["escaneo"] and not (r["progreso"] and r["progreso"]["estado"] == "corriendo"):
|
||||
return r
|
||||
time.sleep(0.1)
|
||||
self.fail("el escaneo no terminó")
|
||||
|
||||
def _arbol(self):
|
||||
out = []
|
||||
for d, _, fs in os.walk(self.raiz):
|
||||
for f in fs:
|
||||
out.append(os.path.relpath(os.path.join(d, f), self.raiz).lower())
|
||||
return sorted(out)
|
||||
|
||||
def test_recorrido(self):
|
||||
q = "?raiz=" + urllib.parse.quote(self.raiz)
|
||||
# peticiones que no son JSON: fuera
|
||||
rq = urllib.request.Request(self.base + "/api/raices", b"{}", {"Content-Type": "text/plain"})
|
||||
with self.assertRaises(urllib.error.HTTPError) as cm:
|
||||
urllib.request.urlopen(rq)
|
||||
self.assertEqual(cm.exception.code, 403)
|
||||
# 1. registrar y escanear
|
||||
self.assertEqual(self.post("/api/raices", {"raices": [{"ruta": self.raiz, "cliente": "Cliente Prueba", "tipo": "contenido"}]})[0], 200)
|
||||
self.assertEqual(self.post("/api/escanear", {"raiz": self.raiz})[0], 200)
|
||||
r = self.esperar_escaneo()
|
||||
self.assertEqual(r["escaneo"]["n"], 6)
|
||||
mapa = self.get("/api/mapa" + q)
|
||||
self.assertTrue(mapa["escaneado"])
|
||||
self.assertGreaterEqual(mapa["basura"]["n"], 1)
|
||||
self.assertGreaterEqual(mapa["proxies"]["n"], 1)
|
||||
# 2. plan: nada sale de la carpeta del cliente
|
||||
plan = self.get("/api/plan" + q)
|
||||
self.assertTrue(plan["filas"], "el plan no propuso nada")
|
||||
for f in plan["filas"]:
|
||||
self.assertTrue(os.path.normpath(f["destino"]).lower().startswith(os.path.normpath(self.raiz).lower()), f["destino"])
|
||||
ids = [f["id"] for f in plan["filas"]]
|
||||
# destino fuera de la carpeta: rechazado
|
||||
c, d = self.post("/api/aplicar", {"raiz": self.raiz, "ids": ids, "destinos": {str(ids[0]): "..\\fuera"}})
|
||||
self.assertEqual(c, 400)
|
||||
self.assertIn("fuera", d["error"])
|
||||
# 3. aplicar: el árbol cambia pero conserva los mismos archivos, y aparece _Para borrar
|
||||
antes = self._arbol()
|
||||
c, res = self.post("/api/aplicar", {"raiz": self.raiz, "ids": ids})
|
||||
self.assertEqual(c, 200)
|
||||
self.assertEqual(res["errores"], [])
|
||||
self.assertGreater(res["movimientos"], 0)
|
||||
despues = self._arbol()
|
||||
self.assertNotEqual(antes, despues)
|
||||
self.assertEqual(sorted(os.path.basename(p) for p in antes), sorted(os.path.basename(p) for p in despues))
|
||||
self.assertTrue(any("_para borrar" in p for p in despues))
|
||||
# 4. deshacer deja el árbol exacto
|
||||
c, res = self.post("/api/deshacer", {"lote": res["lote"]})
|
||||
self.assertEqual(c, 200)
|
||||
self.assertEqual(antes, self._arbol())
|
||||
# 5. desconectar con datos; la segunda vez ya no existe
|
||||
c, d = self.post("/api/raiz/quitar", {"raiz": self.raiz, "datos": True})
|
||||
self.assertEqual(c, 200)
|
||||
self.assertEqual(self.get("/api/estado")["raices"], [])
|
||||
self.assertEqual(self.post("/api/raiz/quitar", {"raiz": self.raiz})[0], 409)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -228,6 +228,7 @@
|
||||
.tarea .b i{display:block;height:100%;background:var(--acc);min-width:2px;transition:width .3s}
|
||||
.tarea .b.ind i{width:30%!important;animation:ind 1.2s infinite linear}
|
||||
.tarea .pct{font-variant-numeric:tabular-nums;color:var(--mut);white-space:nowrap}
|
||||
.tarea .pct small{display:block;font-size:11px;color:var(--mut);font-weight:500;text-align:right}
|
||||
.tarea button{padding:3px 8px;font-size:11.5px}
|
||||
/* avisos */
|
||||
#avisos{position:fixed;right:18px;bottom:60px;display:flex;flex-direction:column;gap:8px;z-index:30;max-width:420px}
|
||||
@@ -558,10 +559,22 @@ function tareasDe(E){
|
||||
const act = E.actualizacion || {}; if (act.estado === 'descargando' || act.estado === 'verificando') T.push({q: 'Descargando versión nueva', d: `${(act.bytes / 1e6).toFixed(0)} de ${(act.total / 1e6).toFixed(0)} MB`, pct: act.pct});
|
||||
return T;
|
||||
}
|
||||
// tiempo restante estimado: se recuerda cuándo se vio cada tarea por primera vez y a qué % iba; con eso se extrapola
|
||||
const ETA = {};
|
||||
function etaDe(t){
|
||||
if (t.pct == null) return '';
|
||||
const k = t.q + '|' + (t.raiz || ''); const ahora = Date.now();
|
||||
const h = ETA[k] && ETA[k].pct <= t.pct ? ETA[k] : (ETA[k] = {t0: ahora, pct: t.pct});
|
||||
const avance = t.pct - h.pct, seg = (ahora - h.t0) / 1000;
|
||||
if (avance < 2 || seg < 8) return '';
|
||||
const resta = seg * (100 - t.pct) / avance;
|
||||
return resta < 60 ? '≈ ' + Math.max(5, Math.round(resta / 5) * 5) + ' s' : resta < 3600 ? '≈ ' + Math.round(resta / 60) + ' min' : '≈ ' + (resta / 3600).toFixed(1) + ' h';
|
||||
}
|
||||
function pintarTareas(){
|
||||
const T = tareasDe(ESTADO);
|
||||
const vivas = new Set(T.map(t => t.q + '|' + (t.raiz || ''))); Object.keys(ETA).forEach(k => { if (!vivas.has(k)) delete ETA[k]; });
|
||||
const caja = $('#tareas'); caja.classList.toggle('on', T.length > 0);
|
||||
caja.innerHTML = '<span class="tit">En curso</span>' + T.map((t, i) => `<div class="tarea run"><div class="w"><div class="q">${esc(t.q)}</div><div class="d" title="${esc(t.d)}">${esc(t.d)}</div><div class="b ${t.pct == null ? 'ind' : ''}"><i style="width:${t.pct == null ? 30 : t.pct}%"></i></div></div>${t.pct != null ? `<span class="pct">${t.pct} %</span>` : ''}${t.cancelar ? `<button data-i="${i}">Detener</button>` : ''}</div>`).join('');
|
||||
caja.innerHTML = '<span class="tit">En curso</span>' + T.map((t, i) => `<div class="tarea run"><div class="w"><div class="q">${esc(t.q)}</div><div class="d" title="${esc(t.d)}">${esc(t.d)}</div><div class="b ${t.pct == null ? 'ind' : ''}"><i style="width:${t.pct == null ? 30 : t.pct}%"></i></div></div>${t.pct != null ? `<span class="pct">${t.pct} %<small>${etaDe(t)}</small></span>` : ''}${t.cancelar ? `<button data-i="${i}">Detener</button>` : ''}</div>`).join('');
|
||||
$$('#tareas button').forEach(b => b.onclick = () => accion(b, async () => { await T[+b.dataset.i].cancelar(); await cargarEstado(); }));
|
||||
}
|
||||
|
||||
|
||||
Referencia en una nueva incidencia
Block a user