"""CardDAV server (SPEC Appendix E.4): address books, vCards in and out, groups as CATEGORIES, and changes flowing both ways with the web app.""" from __future__ import annotations import base64 import re import uuid from collections.abc import AsyncIterator from dataclasses import dataclass import httpx import pytest_asyncio from httpx import ASGITransport from sqlalchemy import delete, text from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from app.config import get_settings from app.db import session_scope from app.identity import app_passwords from app.identity.passwords import hash_password from app.identity.sessions import create_session from app.main import create_app from app.models import User CON = "/api/plugins/contacts" IPHONE_CARD = ( "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:-//Apple Inc.//iPhone OS 18.0//EN\r\n" "N:Kowalska;Anna;;;\r\nFN:Anna Kowalska\r\nORG:Imaginary Pixels;Compositing\r\n" "item1.EMAIL;type=INTERNET;type=WORK;type=pref:anna@ip.example\r\n" "TEL;type=CELL;type=VOICE:+48 600 100 200\r\nCATEGORIES:Clients\r\n" "X-ABUID:ABC123\r\nUID:card-1\r\nEND:VCARD\r\n" ) @dataclass class Ctx: dav: httpx.AsyncClient web: httpx.AsyncClient user: User home: str book_id: str @pytest_asyncio.fixture async def ctx() -> AsyncIterator[Ctx]: engine = create_async_engine(get_settings().database_url, poolclass=NullPool) sm = async_sessionmaker(engine, expire_on_commit=False) async with sm() as s: user = User( handle=f"card{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw") ) s.add(user) await s.commit() async with session_scope() as db: _, password = await app_passwords.create(db, user.id, "test client") transport = ASGITransport(app=create_app()) token = await create_session(user.id, "pytest") web = httpx.AsyncClient( transport=transport, base_url="http://testserver", cookies={"session": token, "csrf": "c"}, headers={"X-CSRF": "c"}, ) auth = "Basic " + base64.b64encode(f"{user.handle}:{password}".encode()).decode() dav = httpx.AsyncClient( transport=transport, base_url="http://testserver", headers={"Authorization": auth} ) home = f"/dav/addressbooks/{user.id}/" listing = await dav.request("PROPFIND", home, headers={"Depth": "1"}) book_id = re.findall(rf"{home}([0-9a-f-]{{36}})/", listing.text)[0] try: yield Ctx(dav, web, user, home, book_id) finally: await dav.aclose() await web.aclose() async with sm() as s: for table in ("plg_contacts_books", "plg_contacts_groups", "plg_contacts_dav_changes"): await s.execute( text(f"DELETE FROM {table} WHERE user_id = :u"), {"u": str(user.id)} ) await s.execute(delete(User).where(User.id == user.id)) await s.commit() await engine.dispose() async def _sync(c: Ctx, token: str = "") -> tuple[str, str]: body = ( '' f"{token}1" "" ) r = await c.dav.request("REPORT", f"{c.home}{c.book_id}/", content=body) assert r.status_code == 207, r.text m = re.search(r"([^<]+)", r.text) assert m return m.group(1), r.text async def test_discovery_finds_the_address_book(ctx: Ctx) -> None: principal = await ctx.dav.request( "PROPFIND", f"/dav/principals/{ctx.user.id}/", headers={"Depth": "0"} ) assert "addressbook-home-set" in principal.text and ctx.home in principal.text book = await ctx.dav.request("PROPFIND", f"{ctx.home}{ctx.book_id}/", headers={"Depth": "0"}) assert "Contacts" in book.text and "addressbook" in book.text async def test_a_phone_contact_and_the_web_app(ctx: Ctx) -> None: url = f"{ctx.home}{ctx.book_id}/ABC123.vcf" token0, _ = await _sync(ctx) put = await ctx.dav.put(url, content=IPHONE_CARD, headers={"If-None-Match": "*"}) assert put.status_code == 201 # The web app shows it, in the group its category named. listed = (await ctx.web.get(f"{CON}/contacts", params={"filter": "all", "q": ""})).json()[ "contacts" ] assert [c["display_name"] for c in listed] == ["Anna Kowalska"] contact = (await ctx.web.get(f"{CON}/contacts/{listed[0]['id']}")).json() assert contact["org"] == "Imaginary Pixels" and contact["department"] == "Compositing" assert ( contact["emails"][0]["value"] == "anna@ip.example" and contact["phones"][0]["type"] == "mobile" ) assert [g["name"] for g in contact["groups"]] == ["Clients"] got = await ctx.dav.get(url) assert got.headers["etag"] == put.headers["etag"] and "X-ABUID:ABC123" in got.text # verbatim token1, body = await _sync(ctx, token0) assert "ABC123.vcf" in body # The web app edits it: the phone gets a regenerated card. edit = dict(contact, display_name="Anna K.", group_ids=[g["id"] for g in contact["groups"]]) assert (await ctx.web.put(f"{CON}/contacts/{contact['id']}", json=edit)).status_code == 200 token2, body = await _sync(ctx, token1) assert "ABC123.vcf" in body got = await ctx.dav.get(url) assert "FN:Anna K." in got.text and "CATEGORIES:Clients" in got.text # Deleting the group changes the contact's CATEGORIES. gid = contact["groups"][0]["id"] assert (await ctx.web.delete(f"{CON}/groups/{gid}")).status_code == 200 token3, body = await _sync(ctx, token2) assert "ABC123.vcf" in body and "CATEGORIES" not in (await ctx.dav.get(url)).text # Multiget, as phones fetch changed cards. multiget = ( '' f"{url}{ctx.home}{ctx.book_id}/missing.vcf" "" ) r = await ctx.dav.request("REPORT", f"{ctx.home}{ctx.book_id}/", content=multiget) assert r.status_code == 207 and "BEGIN:VCARD" in r.text and "404" in r.text # Deleted in the web app: gone for the phone. assert (await ctx.web.delete(f"{CON}/contacts/{contact['id']}")).status_code == 200 _, body = await _sync(ctx, token3) assert "ABC123.vcf" in body and "404" in body