diff --git a/python/functions/infra/__init__.py b/python/functions/infra/__init__.py index 790c94e7..8149afa3 100644 --- a/python/functions/infra/__init__.py +++ b/python/functions/infra/__init__.py @@ -10,6 +10,14 @@ from .hoppscotch_list_requests import hoppscotch_list_requests from .pass_get_secret import pass_get_secret from .hoppscotch_set_environment import hoppscotch_set_environment from .hoppscotch_run_request import hoppscotch_run_request +from .split_vcards import split_vcards +from .split_vevents_to_vcalendars import split_vevents_to_vcalendars +from .extract_or_make_uid import extract_or_make_uid +from .carddav_put_vcard import carddav_put_vcard +from .caldav_put_event import caldav_put_event +from .dav_list_resources import dav_list_resources +from .dav_get_resource import dav_get_resource +from .dav_delete_resource import dav_delete_resource __all__ = [ "setup_logger", @@ -25,4 +33,12 @@ __all__ = [ "pass_get_secret", "hoppscotch_set_environment", "hoppscotch_run_request", + "split_vcards", + "split_vevents_to_vcalendars", + "extract_or_make_uid", + "carddav_put_vcard", + "caldav_put_event", + "dav_list_resources", + "dav_get_resource", + "dav_delete_resource", ] diff --git a/python/functions/infra/dav_delete_resource.md b/python/functions/infra/dav_delete_resource.md new file mode 100644 index 00000000..3e3b7caa --- /dev/null +++ b/python/functions/infra/dav_delete_resource.md @@ -0,0 +1,87 @@ +--- +name: dav_delete_resource +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def dav_delete_resource(base_url: str, username: str, password: str, resource_path: str, *, etag: str = '', timeout_s: float = 20.0, verify_tls: bool = True) -> dict" +description: "Borra (HTTP DELETE) un recurso DAV individual (un VCARD o un VCALENDAR) con HTTP Basic auth. Construye el header Authorization: Basic base64(user:pass) a mano con stdlib. El resource_path puede ser un href absoluto (como los que devuelven dav_list_resources / dav_get_collection) o una URL completa. Opcionalmente envia If-Match: para un borrado condicional que evita pisar una edicion concurrente. DESTRUCTIVO e IRREVERSIBLE: usar con confirmacion explicita, nunca a ciegas. verify_tls=True por defecto. Maneja errores sin lanzar. Solo stdlib (urllib, base64, ssl). Probado contra Xandikos." +tags: [dav, carddav, caldav, delete, remove, http, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [base64, ssl, urllib.error, urllib.request] +params: + - name: base_url + desc: "URL base del servidor DAV. Se ignora si resource_path ya es una URL absoluta." + - name: username + desc: "usuario para HTTP Basic auth (p.ej. 'enmanuel')." + - name: password + desc: "contrasena para HTTP Basic auth. Resolver desde pass con pass_get_secret, nunca hardcodear." + - name: resource_path + desc: "href absoluto (p.ej. '/enmanuel/contacts/addressbook/x.vcf') o URL completa del recurso a borrar. Acepta directamente los hrefs que devuelven dav_list_resources / dav_get_collection." + - name: etag + desc: "etag del recurso para borrado condicional via If-Match. Si se da, el servidor solo borra cuando el etag actual coincide (412 si cambio). Vacio = borrado incondicional." + - name: timeout_s + desc: "timeout de la peticion HTTP en segundos. Default 20.0." + - name: verify_tls + desc: "si True (default) verifica el certificado TLS. No desactivar salvo entorno de prueba." +output: "dict. En exito: {status:'ok', http_status:int, url:str} (DELETE devuelve normalmente 204 No Content o 200). En error (sin lanzar): {status:'error', error:str, http_status:int|None}. Un 404 (ya no existe) llega como error con http_status=404, tratable como idempotente." +tested: true +tests: + - "test_construye_request_delete_con_auth" + - "test_resource_path_relativo_se_resuelve_con_base_url" + - "test_resource_path_absoluto_se_respeta" + - "test_if_match_se_envia_cuando_hay_etag" + - "test_sin_etag_no_envia_if_match" + - "test_204_devuelve_ok" + - "test_404_devuelve_status_error" +test_file_path: "python/functions/infra/dav_delete_resource_test.py" +file_path: "python/functions/infra/dav_delete_resource.py" +--- + +## Ejemplo + +```python +import sys +sys.path.insert(0, "python/functions") +from infra.pass_get_secret import pass_get_secret +from infra.carddav_put_vcard import carddav_put_vcard +from infra.dav_delete_resource import dav_delete_resource + +base = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com" +coll = "/enmanuel/contacts/addressbook/" +pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear + +# Sube un vCard de prueba y luego lo borra (limpieza de un test): +vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Tmp\r\nUID:zz-tmp\r\nEND:VCARD\r\n" +carddav_put_vcard(base, "enmanuel", pw, coll, "zz-tmp", vcard) +res = dav_delete_resource(base, "enmanuel", pw, coll + "zz-tmp.vcf") +print(res["status"], res["http_status"]) # ok 204 +``` + +## Cuando usarla + +Cuando necesitas RETIRAR un recurso de una coleccion CardDAV/CalDAV: limpiar el +vCard de prueba que subiste para validar un sync, borrar un contacto obsoleto, +o eliminar un evento cancelado. Completa el CRUD del grupo `dav` (put / get / +list / get-collection / **delete**). Para limpieza segura tras un test usa el +href que devuelve `carddav_put_vcard` (campo `url`) o el `href` de +`dav_get_collection`. + +## Gotchas + +- DESTRUCTIVO e IRREVERSIBLE en el servidor. No llamarla en un bucle de sync sin + confirmacion explicita (`confirm=True` / `--yes` en el caller). Pensada para + acciones puntuales controladas, no para reconciliacion automatica. +- Un 404 (el recurso ya no existe) llega como `{status:'error', http_status:404}`. + Para un borrado idempotente, el caller puede tratar 404 como exito ("ya estaba + borrado"). +- Pasa `etag` para borrado condicional (If-Match): si el recurso cambio desde que + lo leiste, el servidor responde 412 Precondition Failed y NO borra — evita + pisar una edicion concurrente del movil. +- Borrado remoto real sobre TLS; password de `pass`, no se logea. +- `verify_tls=False` solo en pruebas; abre MITM. diff --git a/python/functions/infra/dav_delete_resource.py b/python/functions/infra/dav_delete_resource.py new file mode 100644 index 00000000..c910b09a --- /dev/null +++ b/python/functions/infra/dav_delete_resource.py @@ -0,0 +1,83 @@ +"""Borra (DELETE) un recurso DAV individual via HTTP Basic auth. + +Funcion impura: hace una peticion HTTP DELETE. Construye el header +`Authorization: Basic base64(user:pass)` a mano con stdlib. El resource_path +puede ser un href absoluto (como los que devuelve dav_list_resources / +dav_get_collection) o una ruta relativa al base_url. Opcionalmente envia el +header `If-Match: ` para un borrado condicional (solo borra si el etag +coincide, evita pisar una edicion concurrente). Maneja errores sin lanzar. +Solo usa stdlib (urllib, base64, ssl). + +ATENCION: DELETE es DESTRUCTIVO e IRREVERSIBLE en el servidor. Usar con +confirmacion explicita del caller (nunca a ciegas en un bucle de sync). Pensado +para limpiar recursos de prueba o retirar contactos obsoletos de forma +controlada. +""" + +import base64 +import ssl +import urllib.error +import urllib.request + + +def _basic_auth_header(username: str, password: str) -> str: + raw = ("%s:%s" % (username, password)).encode("utf-8") + return "Basic " + base64.b64encode(raw).decode("ascii") + + +def _resolve_url(base_url: str, resource_path: str) -> str: + if resource_path.startswith("http://") or resource_path.startswith("https://"): + return resource_path + return base_url.rstrip("/") + "/" + resource_path.lstrip("/") + + +def dav_delete_resource( + base_url: str, + username: str, + password: str, + resource_path: str, + *, + etag: str = "", + timeout_s: float = 20.0, + verify_tls: bool = True, +) -> dict: + """Borra un recurso DAV (DELETE). DESTRUCTIVO e IRREVERSIBLE. + + Args: + base_url: URL base del servidor DAV. Se ignora si resource_path ya es + una URL absoluta. + username: usuario para HTTP Basic auth. + password: contrasena para HTTP Basic auth. Resolver desde pass. + resource_path: href absoluto (p.ej. '/enmanuel/contacts/addressbook/x.vcf') + o URL completa del recurso a borrar. Acepta directamente los hrefs + que devuelven dav_list_resources / dav_get_collection. + etag: si se da, se envia como header If-Match para un borrado + condicional (el servidor solo borra si el etag actual coincide; + devuelve 412 Precondition Failed si cambio). Vacio = borrado + incondicional. + timeout_s: timeout de la peticion en segundos. Default 20.0. + verify_tls: si True (default) verifica el certificado TLS. + + Returns: + dict. En exito: {status:'ok', http_status:int, url:str} (DELETE devuelve + normalmente 204 No Content o 200). En error (sin lanzar): + {status:'error', error:str, http_status:int|None}. Un 404 (ya no existe) + se devuelve como error con http_status=404; el caller puede tratarlo + como idempotente (ya borrado). + """ + url = _resolve_url(base_url, resource_path) + headers = {"Authorization": _basic_auth_header(username, password)} + if etag: + headers["If-Match"] = etag + req = urllib.request.Request(url, method="DELETE", headers=headers) + + context = None if verify_tls else ssl._create_unverified_context() + try: + with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp: + return {"status": "ok", "http_status": resp.status, "url": url} + except urllib.error.HTTPError as e: + return {"status": "error", "error": "http %s" % e.code, "http_status": e.code} + except urllib.error.URLError as e: + return {"status": "error", "error": str(e.reason), "http_status": None} + except Exception as e: # noqa: BLE001 + return {"status": "error", "error": str(e), "http_status": None} diff --git a/python/functions/infra/dav_delete_resource_test.py b/python/functions/infra/dav_delete_resource_test.py new file mode 100644 index 00000000..81e19c1d --- /dev/null +++ b/python/functions/infra/dav_delete_resource_test.py @@ -0,0 +1,96 @@ +"""Tests para dav_delete_resource. + +Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el +Request (method DELETE, auth, URL, headers If-Match) y simular respuestas. +""" + +import base64 +import sys + +import infra.dav_delete_resource # noqa: F401 + +mod = sys.modules["infra.dav_delete_resource"] +dav_delete_resource = mod.dav_delete_resource + + +class _FakeResp: + def __init__(self, status=204): + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +def _capture(monkeypatch, status=204): + captured = {} + + def fake_urlopen(req, timeout=None, context=None): + captured["url"] = req.full_url + captured["method"] = req.get_method() + captured["headers"] = {k.lower(): v for k, v in req.header_items()} + return _FakeResp(status) + + monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen) + return captured + + +def test_construye_request_delete_con_auth(monkeypatch): + cap = _capture(monkeypatch) + res = dav_delete_resource( + "https://dav.example.com", "enmanuel", "secret-pw", + "/enmanuel/contacts/addressbook/ada.vcf", + ) + assert res["status"] == "ok" + assert cap["method"] == "DELETE" + expected = "Basic " + base64.b64encode(b"enmanuel:secret-pw").decode("ascii") + assert cap["headers"]["authorization"] == expected + + +def test_resource_path_relativo_se_resuelve_con_base_url(monkeypatch): + cap = _capture(monkeypatch) + dav_delete_resource( + "https://dav.example.com", "u", "p", + "/enmanuel/contacts/addressbook/ada.vcf", + ) + assert cap["url"] == "https://dav.example.com/enmanuel/contacts/addressbook/ada.vcf" + + +def test_resource_path_absoluto_se_respeta(monkeypatch): + cap = _capture(monkeypatch) + abs_url = "https://otra.example.com/path/x.vcf" + dav_delete_resource("https://dav.example.com", "u", "p", abs_url) + assert cap["url"] == abs_url + + +def test_if_match_se_envia_cuando_hay_etag(monkeypatch): + cap = _capture(monkeypatch) + dav_delete_resource( + "https://dav.example.com", "u", "p", "/x.vcf", etag='"abc123"', + ) + assert cap["headers"]["if-match"] == '"abc123"' + + +def test_sin_etag_no_envia_if_match(monkeypatch): + cap = _capture(monkeypatch) + dav_delete_resource("https://dav.example.com", "u", "p", "/x.vcf") + assert "if-match" not in cap["headers"] + + +def test_204_devuelve_ok(monkeypatch): + _capture(monkeypatch, status=204) + res = dav_delete_resource("https://dav.example.com", "u", "p", "/x.vcf") + assert res["status"] == "ok" + assert res["http_status"] == 204 + + +def test_404_devuelve_status_error(monkeypatch): + def fake_urlopen(req, timeout=None, context=None): + raise mod.urllib.error.HTTPError(req.full_url, 404, "Not Found", {}, None) + + monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen) + res = dav_delete_resource("https://dav.example.com", "u", "p", "/x.vcf") + assert res["status"] == "error" + assert res["http_status"] == 404