232 lines
8.5 KiB
Python
232 lines
8.5 KiB
Python
"""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'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>'
|
||
|
|
|
||
|
|
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 (
|
||
|
|
'<?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>"
|
||
|
|
).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"<d:sync-token>([^<]+)</d:sync-token>", 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
|