feat: push de agenda sin OSINT (compone persona enlazada) + sync inverso por etag
Privacidad (decisión del usuario: al móvil solo datos de agenda): - _compose_agenda_vcard compone el vCard desde el contacto (fn/tels/emails) + las direcciones (ADR) y aliases (NICKNAME) de la persona enlazada por note_path, SIN pasar nunca el dict osint a build_vcard → el vCard jamás lleva X-OSINT-* (DNI/sexo/fecha-nac quedan solo en DuckDB+Obsidian). Usado en upsert_contact y en el push masivo push_all_dav (que antes leía solo contacts y perdía las direcciones). Sync inverso DAVx5→DuckDB (last-write-wins por etag): - Tras cada push se captura el etag nuevo del recurso (dav_list_resources) y se persiste en contacts.etag, para no confundir el push propio con una edición del móvil. - POST /api/sync/dav-pull: pull incremental — compara etags, descarga SOLO los recursos cambiados/nuevos (dav_get_resource + parse_vcard + upsert), borra los que el móvil quitó, re-enlaza. Distinto del ingest_dav (DELETE+INSERT ciego): respeta la verdad de la DB salvo donde el etag prueba un cambio externo. 20 tests verdes (18 + 2 nuevos: vCard sin OSINT con direcciones; pull incremental por etag).
This commit is contained in:
@@ -541,3 +541,162 @@ def test_api_person_render_preserva_prosa(client, cfg):
|
||||
after = open(note_file, encoding="utf-8").read()
|
||||
assert "Ficha de prueba." in after # prosa preservada
|
||||
assert "+34 622 333 444" in after # frontmatter actualizado
|
||||
|
||||
|
||||
# --- F4: push de agenda SIN OSINT + sync inverso por etag ------------------
|
||||
|
||||
|
||||
def test_compose_agenda_vcard_sin_osint_con_direcciones(client, cfg, monkeypatch):
|
||||
"""upsert_contact compone un vCard de agenda con ADR de la persona y SIN X-OSINT.
|
||||
|
||||
Crea una persona con DNI/sexo/dirección y un contacto enlazado por teléfono;
|
||||
captura el vCard que se manda a Xandikos y verifica que (a) NO lleva ninguna
|
||||
línea X-OSINT-*, (b) SÍ lleva la dirección de la persona como ADR.
|
||||
"""
|
||||
from server import writes
|
||||
|
||||
client.post("/api/ingest/vault")
|
||||
|
||||
# Persona con campos OSINT (dni/sexo/fecha_nac) + direccion + alias.
|
||||
client.post(
|
||||
"/api/person",
|
||||
json={
|
||||
"slug": "marca-osint",
|
||||
"nombre": "Marca Osint",
|
||||
"dni": "99999999R",
|
||||
"sexo": "mujer",
|
||||
"fecha_nacimiento": "1985-01-01",
|
||||
"pais": "españa",
|
||||
"contexto": "investigacion",
|
||||
"telefonos": ["+34 655 100 200"],
|
||||
"direcciones": ["Calle Falsa 123, Madrid"],
|
||||
"aliases": ["La Marca"],
|
||||
},
|
||||
)
|
||||
# El contacto se enlaza a la ficha por teléfono al re-ingestar el vault.
|
||||
with write_conn(cfg.db_path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
"uid-marca",
|
||||
"/enmanuel/contacts/addressbook/",
|
||||
None,
|
||||
"Marca Osint",
|
||||
'["+34 655 100 200"]',
|
||||
"[]",
|
||||
"BEGIN:VCARD...",
|
||||
None,
|
||||
datetime.now(tz=timezone.utc),
|
||||
],
|
||||
)
|
||||
client.post("/api/ingest/vault") # enlaza uid-marca -> personas/marca-osint.md
|
||||
|
||||
# Capturamos el vCard que upsert_contact empuja a Xandikos.
|
||||
pushed_vcards: list = []
|
||||
|
||||
def fake_put(base, user, pwd, collection, uid, vcard, **kw):
|
||||
pushed_vcards.append(vcard)
|
||||
return {"status": "ok", "http_status": 201, "url": "x"}
|
||||
|
||||
monkeypatch.setattr(writes, "pass_get_secret", lambda *_: {"status": "ok", "value": "pw"})
|
||||
monkeypatch.setattr(writes, "carddav_put_vcard", fake_put)
|
||||
monkeypatch.setattr(
|
||||
writes,
|
||||
"dav_list_resources",
|
||||
lambda *a, **k: {
|
||||
"status": "ok",
|
||||
"http_status": 207,
|
||||
"resources": [{"href": "/enmanuel/contacts/addressbook/uid-marca.vcf", "etag": '"newtag"'}],
|
||||
},
|
||||
)
|
||||
|
||||
r = client.put(
|
||||
"/api/contact/uid-marca",
|
||||
json={"uid": "uid-marca", "fn": "Marca Osint", "tels": ["+34 655 100 200"]},
|
||||
).json()
|
||||
assert r["status"] == "ok"
|
||||
assert r["pushed"] is True
|
||||
assert r["etag"] == '"newtag"' # etag del recurso capturado tras el push
|
||||
|
||||
assert pushed_vcards, "no se empujó ningún vCard"
|
||||
vcard = pushed_vcards[-1]
|
||||
# (a) Privacidad: ninguna línea X-OSINT-* (DNI/sexo/fecha-nac/pais/contexto).
|
||||
assert "X-OSINT-" not in vcard
|
||||
assert "99999999R" not in vcard # el DNI no se filtra al móvil
|
||||
# (b) Direcciones y alias de la persona enlazada SÍ viajan (agenda).
|
||||
assert "ADR;TYPE=HOME:;;Calle Falsa 123\\, Madrid;;;;" in vcard
|
||||
assert "NICKNAME:La Marca" in vcard
|
||||
|
||||
# El etag nuevo quedó persistido en la DB (sync inverso fiable).
|
||||
q = client.post(
|
||||
"/api/query", json={"sql": "SELECT etag, raw FROM contacts WHERE uid = 'uid-marca'"}
|
||||
).json()
|
||||
assert q["rows"][0]["etag"] == '"newtag"'
|
||||
assert "X-OSINT-" not in q["rows"][0]["raw"] # el raw guardado tampoco lleva OSINT
|
||||
|
||||
|
||||
def test_pull_dav_incremental_por_etag(client, cfg, monkeypatch):
|
||||
"""pull_dav actualiza solo el etag cambiado, no toca el igual y borra el ausente."""
|
||||
from server import writes
|
||||
|
||||
client.post("/api/ingest/vault")
|
||||
|
||||
coll = "/enmanuel/contacts/addressbook/"
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
# Tres contactos en la DB con etags conocidos.
|
||||
with write_conn(cfg.db_path) as conn:
|
||||
for uid, etag, fn in [
|
||||
("c-same", '"e-same"', "Sin Cambios"),
|
||||
("c-changed", '"e-old"', "Antes"),
|
||||
("c-gone", '"e-gone"', "Se Borra"),
|
||||
]:
|
||||
conn.execute(
|
||||
"INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[uid, coll, etag, fn, "[]", "[]", "BEGIN:VCARD...", None, now],
|
||||
)
|
||||
|
||||
# Remoto: c-same igual, c-changed con etag nuevo, c-gone ausente.
|
||||
def fake_list(base, user, pwd, collection, **kw):
|
||||
return {
|
||||
"status": "ok",
|
||||
"http_status": 207,
|
||||
"resources": [
|
||||
{"href": coll + "c-same.vcf", "etag": '"e-same"'},
|
||||
{"href": coll + "c-changed.vcf", "etag": '"e-new"'},
|
||||
],
|
||||
}
|
||||
|
||||
def fake_get(base, user, pwd, href, **kw):
|
||||
# Solo debería pedirse el recurso cambiado (c-same tiene etag igual).
|
||||
assert "c-changed" in href, f"GET inesperado de {href}"
|
||||
return {
|
||||
"status": "ok",
|
||||
"http_status": 200,
|
||||
"text": (
|
||||
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c-changed\r\n"
|
||||
"FN:Despues\r\nTEL;TYPE=CELL:+34 600 000 000\r\nEND:VCARD\r\n"
|
||||
),
|
||||
"url": href,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(writes, "pass_get_secret", lambda *_: {"status": "ok", "value": "pw"})
|
||||
monkeypatch.setattr(writes, "dav_list_resources", fake_list)
|
||||
monkeypatch.setattr(writes, "dav_get_resource", fake_get)
|
||||
|
||||
r = client.post("/api/sync/dav-pull").json()
|
||||
assert r["status"] == "ok"
|
||||
assert r["pulled"] == 1 # solo c-changed se descargó
|
||||
assert r["updated"] == 1 # y era una actualización (ya existía)
|
||||
assert r["unchanged"] == 1 # c-same no se tocó
|
||||
assert r["deleted"] == 1 # c-gone se borró (ausente en el PROPFIND)
|
||||
|
||||
rows = client.post(
|
||||
"/api/query",
|
||||
json={"sql": "SELECT uid, etag, fn FROM contacts ORDER BY uid"},
|
||||
).json()["rows"]
|
||||
by_uid = {row["uid"]: row for row in rows}
|
||||
assert set(by_uid) == {"c-changed", "c-same"} # c-gone desapareció
|
||||
assert by_uid["c-same"]["fn"] == "Sin Cambios" # intacto
|
||||
assert by_uid["c-same"]["etag"] == '"e-same"'
|
||||
assert by_uid["c-changed"]["fn"] == "Despues" # FN del vCard nuevo
|
||||
assert by_uid["c-changed"]["etag"] == '"e-new"' # etag remoto persistido
|
||||
|
||||
Reference in New Issue
Block a user