"""CalDAV server (SPEC Appendix E): discovery, sign-in with app passwords, and two-way changes between DAV clients and the web app on the same rows.""" 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 CAL = "/api/plugins/calendar" PROPFIND_ALL = b'' EVENT = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//test//EN BEGIN:VEVENT UID:{uid} DTSTAMP:20261009T120000Z DTSTART;TZID=Europe/Warsaw:20311013T090000 DTEND;TZID=Europe/Warsaw:20311013T100000 SUMMARY:{summary} X-CLIENT-ONLY:keep me BEGIN:VALARM ACTION:DISPLAY DESCRIPTION:x TRIGGER:-PT30M END:VALARM END:VEVENT END:VCALENDAR """ @dataclass class Ctx: dav: httpx.AsyncClient # a CalDAV client (Basic, app password) web: httpx.AsyncClient # the web app (session) anon: httpx.AsyncClient user: User app_password: str app_password_id: uuid.UUID home: str calendar_id: str def _basic(login: str, password: str) -> dict[str, str]: return {"Authorization": "Basic " + base64.b64encode(f"{login}:{password}".encode()).decode()} @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"dav{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("account-pw"), ) s.add(user) await s.commit() async with session_scope() as db: row, 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"}, ) calendar_id = (await web.get(f"{CAL}/calendars")).json()[0]["id"] # bootstraps "Personal" dav = httpx.AsyncClient( transport=transport, base_url="http://testserver", headers=_basic(user.handle, password) ) anon = httpx.AsyncClient(transport=transport, base_url="http://testserver") try: yield Ctx(dav, web, anon, user, password, row.id, f"/dav/calendars/{user.id}/", calendar_id) finally: for c in (dav, web, anon): await c.aclose() async with sm() as s: await s.execute( text("DELETE FROM plg_calendar_calendars WHERE user_id = :u"), {"u": str(user.id)} ) await s.execute( text("DELETE FROM plg_calendar_dav_changes WHERE user_id = :u"), {"u": str(user.id)} ) await s.execute(delete(User).where(User.id == user.id)) await s.commit() await engine.dispose() def _sync_report(token: str = "") -> bytes: return ( '' f"{token}1" "" ).encode() async def _sync(c: Ctx, token: str = "") -> tuple[str, str]: r = await c.dav.request("REPORT", f"{c.home}{c.calendar_id}/", content=_sync_report(token)) assert r.status_code == 207, r.text new = re.search(r"([^<]+)", r.text) assert new return new.group(1), r.text async def test_discovery_and_sign_in(ctx: Ctx) -> None: assert (await ctx.anon.get("/.well-known/caldav")).headers["location"] == "/dav/" assert (await ctx.anon.request("PROPFIND", "/dav/")).status_code == 401 # The account password never works here — only app passwords. wrong = await ctx.anon.request( "PROPFIND", "/dav/", headers=_basic(ctx.user.handle, "account-pw") ) assert wrong.status_code == 401 root = await ctx.dav.request("PROPFIND", "/dav/", headers={"Depth": "0"}, content=PROPFIND_ALL) assert root.status_code == 207 and f"/dav/principals/{ctx.user.id}/" in root.text principal = await ctx.dav.request( "PROPFIND", f"/dav/principals/{ctx.user.id}/", headers={"Depth": "0"} ) assert ctx.home in principal.text and "calendar-home-set" in principal.text home = await ctx.dav.request("PROPFIND", ctx.home, headers={"Depth": "1"}) assert ( home.status_code == 207 and "Personal" in home.text and f"{ctx.calendar_id}/" in home.text ) # Someone else's home: refused. other = await ctx.dav.request( "PROPFIND", f"/dav/calendars/{uuid.uuid4()}/", headers={"Depth": "1"} ) assert other.status_code == 403 # Revoking the app password locks the client out at once (no stale cache). async with session_scope() as db: await app_passwords.revoke(db, ctx.user.id, ctx.app_password_id) assert (await ctx.dav.request("PROPFIND", "/dav/")).status_code == 401 async def test_client_and_web_app_see_each_others_changes(ctx: Ctx) -> None: url = f"{ctx.home}{ctx.calendar_id}/review.ics" token0, _ = await _sync(ctx) # A client adds an event: the web app shows it, alarm included. put = await ctx.dav.put( url, content=EVENT.format(uid="dav-1@test", summary="Review"), headers={"If-None-Match": "*"}, ) assert put.status_code == 201 etag = put.headers["etag"] events = ( await ctx.web.get( f"{CAL}/events", params={"start": "2031-10-13T00:00:00Z", "end": "2031-10-14T00:00:00Z"} ) ).json()["events"] assert [(e["summary"], e["reminders"], e["tz"]) for e in events] == [ ("Review", [30], "Europe/Warsaw") ] event_id = events[0]["id"] got = await ctx.dav.get(url) assert got.headers["etag"] == etag and "X-CLIENT-ONLY:keep me" in got.text # verbatim token1, body = await _sync(ctx, token0) assert "review.ics" in body # Creating it again, or editing an old version, is refused. again = await ctx.dav.put( url, content=EVENT.format(uid="dav-1@test", summary="x"), headers={"If-None-Match": "*"} ) assert again.status_code == 412 stale = await ctx.dav.put( url, content=EVENT.format(uid="dav-1@test", summary="x"), headers={"If-Match": '"nope"'} ) assert stale.status_code == 412 # The web app renames it: the client gets the new version (regenerated). assert ( await ctx.web.patch(f"{CAL}/events/{event_id}", json={"summary": "Review (web)"}) ).status_code == 200 token2, body = await _sync(ctx, token1) assert "review.ics" in body and token2 != token1 got = await ctx.dav.get(url) assert got.headers["etag"] != etag and "SUMMARY:Review (web)" in got.text assert "TRIGGER:-PT30M" in got.text and "TZID=Europe/Warsaw" in got.text # The web app deletes it: the client hears it is gone. assert (await ctx.web.delete(f"{CAL}/events/{event_id}")).status_code == 200 _, body = await _sync(ctx, token2) assert "review.ics" in body and "404" in body assert (await ctx.dav.get(url)).status_code == 404 async def test_client_delete_and_read_only_calendars(ctx: Ctx) -> None: url = f"{ctx.home}{ctx.calendar_id}/gone.ics" put = await ctx.dav.put(url, content=EVENT.format(uid="dav-2@test", summary="Temp")) assert put.status_code == 201 assert (await ctx.dav.delete(url, headers={"If-Match": put.headers["etag"]})).status_code == 204 events = ( await ctx.web.get( f"{CAL}/events", params={"start": "2031-10-13T00:00:00Z", "end": "2031-10-14T00:00:00Z"} ) ).json()["events"] assert events == [] # A subscribed (read-only) calendar refuses writes. async with session_scope() as db: sub = await db.scalar( text( "INSERT INTO plg_calendar_calendars (user_id, name, kind, read_only) " "VALUES (:u, 'Holidays', 'ics_subscription', true) RETURNING id" ), {"u": str(ctx.user.id)}, ) ro = await ctx.dav.put( f"{ctx.home}{sub}/x.ics", content=EVENT.format(uid="dav-3@test", summary="x") ) assert ro.status_code == 403