Files
webmail-client/backend/tests/plugins/test_contacts_dav.py
T

161 lines
6.4 KiB
Python
Raw Normal View History

"""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 = (
'<?xml version="1.0"?><d:sync-collection xmlns:d="DAV:">'
f"<d:sync-token>{token}</d:sync-token><d:sync-level>1</d:sync-level>"
"<d:prop><d:getetag/></d:prop></d:sync-collection>"
)
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"<d:sync-token>([^<]+)</d:sync-token>", 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 = (
'<?xml version="1.0"?><card:addressbook-multiget xmlns:d="DAV:" '
'xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><card:address-data/>'
f"</d:prop><d:href>{url}</d:href><d:href>{ctx.home}{ctx.book_id}/missing.vcf</d:href>"
"</card:addressbook-multiget>"
)
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