feat(infra): dav_delete_resource — DELETE de recurso CardDAV/CalDAV (grupo dav)
Completa el CRUD del grupo dav (put/get/list/get-collection/delete). HTTP DELETE con Basic auth, If-Match opcional para borrado condicional, maneja 404 como idempotente. Solo stdlib. 7 tests deterministas (monkeypatch urlopen). Probado contra Xandikos real durante la limpieza del ciclo de sync OSINT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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: <etag> 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.
|
||||
@@ -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: <etag>` 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}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user