🏗️(caldav) migrate from davical to sabre/dav

This commit is contained in:
Sylvain Zimmer
2026-01-11 02:28:04 +01:00
parent a36348ead1
commit bc801d3007
35 changed files with 1059 additions and 549 deletions

View File

@@ -67,9 +67,9 @@ class Base(Configuration):
API_VERSION = "v1.0"
# DAViCal CalDAV server URL
DAVICAL_URL = values.Value(
"http://davical:80", environ_name="DAVICAL_URL", environ_prefix=None
# CalDAV server URL
CALDAV_URL = values.Value(
"http://caldav:80", environ_name="CALDAV_URL", environ_prefix=None
)
# Security

View File

@@ -1,4 +1,4 @@
"""CalDAV proxy views for forwarding requests to DAViCal."""
"""CalDAV proxy views for forwarding requests to CalDAV server."""
import logging
@@ -10,15 +10,13 @@ from django.views.decorators.csrf import csrf_exempt
import requests
from core.services.caldav_service import DAViCalClient
logger = logging.getLogger(__name__)
@method_decorator(csrf_exempt, name="dispatch")
class CalDAVProxyView(View):
"""
Proxy view that forwards all CalDAV requests to DAViCal.
Proxy view that forwards all CalDAV requests to CalDAV server.
Handles authentication and adds appropriate headers.
CSRF protection is disabled because CalDAV uses non-standard HTTP methods
@@ -27,7 +25,7 @@ class CalDAVProxyView(View):
"""
def dispatch(self, request, *args, **kwargs):
"""Forward all HTTP methods to DAViCal."""
"""Forward all HTTP methods to CalDAV server."""
# Handle CORS preflight requests
if request.method == "OPTIONS":
response = HttpResponse(status=200)
@@ -42,83 +40,40 @@ class CalDAVProxyView(View):
if not request.user.is_authenticated:
return HttpResponse(status=401)
# Ensure user exists in DAViCal before making requests
try:
davical_client = DAViCalClient()
davical_client.ensure_user_exists(request.user)
except Exception as e:
logger.warning("Failed to ensure user exists in DAViCal: %s", str(e))
# Continue anyway - user might already exist
# Build the DAViCal URL
davical_url = getattr(settings, "DAVICAL_URL", "http://davical:80")
# Build the CalDAV server URL
caldav_url = settings.CALDAV_URL
path = kwargs.get("path", "")
# Use user email as the principal (DAViCal uses email as username)
# Use user email as the principal (CalDAV server uses email as username)
user_principal = request.user.email
# Handle root CalDAV requests - return principal collection
if not path or path == user_principal:
# For PROPFIND on root, return the user's principal collection
if request.method == "PROPFIND":
# Get the request path to match the href in response
request_path = request.path
if not request_path.endswith("/"):
request_path += "/"
# Build target URL - CalDAV server uses base URI /api/v1.0/caldav/
# The proxy receives requests at /api/v1.0/caldav/... and forwards them
# to the CalDAV server at the same path (sabre/dav expects requests at its base URI)
base_uri_path = "/api/v1.0/caldav"
clean_path = path.lstrip("/") if path else ""
# Return multistatus with href matching request URL and calendar-home-set
multistatus = f"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>{request_path}</D:href>
<D:propstat>
<D:prop>
<D:displayname>{user_principal}</D:displayname>
<C:calendar-home-set>
<D:href>/api/v1.0/caldav/{user_principal}/</D:href>
</C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
response = HttpResponse(
content=multistatus,
status=207,
content_type="application/xml; charset=utf-8",
)
return response
# For other methods, redirect to principal URL
target_url = f"{davical_url}/caldav.php/{user_principal}/"
# Construct target URL - always include the base URI path
if clean_path:
target_url = f"{caldav_url}{base_uri_path}/{clean_path}"
else:
# Build target URL with path
# Remove leading slash if present
clean_path = path.lstrip("/")
if clean_path.startswith(user_principal):
# Path already includes principal
target_url = f"{davical_url}/caldav.php/{clean_path}"
else:
# Path is relative to principal
target_url = f"{davical_url}/caldav.php/{user_principal}/{clean_path}"
# Root request - use base URI path
target_url = f"{caldav_url}{base_uri_path}/"
# Prepare headers for DAViCal
# Set headers to tell DAViCal it's behind a proxy so it generates correct URLs
script_name = "/api/v1.0/caldav"
# Prepare headers for CalDAV server
# CalDAV server Apache backend reads REMOTE_USER, which we set via X-Forwarded-User
headers = {
"Content-Type": request.content_type or "application/xml",
"X-Forwarded-User": user_principal,
"X-Forwarded-For": request.META.get("REMOTE_ADDR", ""),
"X-Forwarded-Prefix": script_name,
"X-Forwarded-Host": request.get_host(),
"X-Forwarded-Proto": request.scheme,
"X-Script-Name": script_name, # Tell DAViCal the base path
}
# DAViCal authentication: users with password '*' use external auth
# CalDAV server authentication: Apache backend reads REMOTE_USER
# We send the username via X-Forwarded-User header
# For HTTP Basic Auth, we use the email as username with empty password
# This works with DAViCal's external authentication when trust_x_forwarded is true
# CalDAV server converts X-Forwarded-User to REMOTE_USER
auth = (user_principal, "")
# Copy relevant headers from the original request
@@ -135,11 +90,11 @@ class CalDAVProxyView(View):
body = request.body if request.body else None
try:
# Forward the request to DAViCal
# Forward the request to CalDAV server
# Use HTTP Basic Auth with username (email) and empty password
# DAViCal will authenticate based on X-Forwarded-User header when trust_x_forwarded is true
# CalDAV server will authenticate based on X-Forwarded-User header (converted to REMOTE_USER)
logger.debug(
"Forwarding %s request to DAViCal: %s (user: %s)",
"Forwarding %s request to CalDAV server: %s (user: %s)",
request.method,
target_url,
user_principal,
@@ -157,7 +112,7 @@ class CalDAVProxyView(View):
# Log authentication failures for debugging
if response.status_code == 401:
logger.warning(
"DAViCal returned 401 for user %s at %s. Headers sent: %s",
"CalDAV server returned 401 for user %s at %s. Headers sent: %s",
user_principal,
target_url,
headers,
@@ -170,7 +125,7 @@ class CalDAVProxyView(View):
content_type=response.headers.get("Content-Type", "application/xml"),
)
# Copy relevant headers from DAViCal response
# Copy relevant headers from CalDAV server response
for header in ["ETag", "DAV", "Allow", "Location"]:
if header in response.headers:
django_response[header] = response.headers[header]
@@ -178,7 +133,7 @@ class CalDAVProxyView(View):
return django_response
except requests.exceptions.RequestException as e:
logger.error("DAViCal proxy error: %s", str(e))
logger.error("CalDAV server proxy error: %s", str(e))
return HttpResponse(
content=f"CalDAV server error: {str(e)}",
status=502,

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.9 on 2026-01-08 23:49
# Generated by Django 5.2.9 on 2026-01-11 00:45
import core.models
import django.core.validators
@@ -59,7 +59,7 @@ class Migration(migrations.Migration):
('description', models.TextField(blank=True, default='')),
('is_default', models.BooleanField(default=False)),
('is_visible', models.BooleanField(default=True)),
('davical_path', models.CharField(max_length=512, unique=True)),
('caldav_path', models.CharField(max_length=512, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calendars', to=settings.AUTH_USER_MODEL)),

View File

@@ -324,7 +324,7 @@ class BaseAccess(BaseModel):
class Calendar(models.Model):
"""
Represents a calendar owned by a user.
This model tracks calendars stored in DAViCal and links them to Django users.
This model tracks calendars stored in the CalDAV server and links them to Django users.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -339,8 +339,8 @@ class Calendar(models.Model):
is_default = models.BooleanField(default=False)
is_visible = models.BooleanField(default=True)
# DAViCal reference - the calendar path in DAViCal
davical_path = models.CharField(max_length=512, unique=True)
# CalDAV server reference - the calendar path in the CalDAV server
caldav_path = models.CharField(max_length=512, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

View File

@@ -1,4 +1,4 @@
"""Services for CalDAV integration with DAViCal."""
"""Services for CalDAV integration."""
import logging
from datetime import date, datetime, timedelta
@@ -8,8 +8,6 @@ from uuid import uuid4
from django.conf import settings
from django.utils import timezone
import psycopg
from caldav import DAVClient
from caldav.lib.error import NotFoundError
from core.models import Calendar
@@ -17,131 +15,47 @@ from core.models import Calendar
logger = logging.getLogger(__name__)
class DAViCalClient:
class CalDAVClient:
"""
Client for communicating with DAViCal CalDAV server using the caldav library.
Client for communicating with CalDAV server using the caldav library.
"""
def __init__(self):
self.base_url = getattr(settings, "DAVICAL_URL", "http://davical:80")
self.base_url = settings.CALDAV_URL
# Set the base URI path as expected by the CalDAV server
self.base_uri_path = "/api/v1.0/caldav/"
self.timeout = 30
def _get_client(self, user) -> DAVClient:
"""
Get a CalDAV client for the given user.
DAViCal uses X-Forwarded-User header for authentication. The caldav
library requires username/password for Basic Auth, but DAViCal users have
password '*' (external auth). We pass the X-Forwarded-User header directly
to the DAVClient constructor.
The CalDAV server uses Apache authentication backend which reads REMOTE_USER.
We pass the X-Forwarded-User header which the server converts to REMOTE_USER.
The caldav library requires username/password for Basic Auth, but we use
empty password since authentication is handled via headers.
"""
# DAViCal base URL - the caldav library will discover the principal
caldav_url = f"{self.base_url}/caldav.php/"
# CalDAV server base URL - include the base URI path that sabre/dav expects
# Remove trailing slash from base_url and base_uri_path to avoid double slashes
base_url_clean = self.base_url.rstrip("/")
base_uri_clean = self.base_uri_path.rstrip("/")
caldav_url = f"{base_url_clean}{base_uri_clean}/"
return DAVClient(
url=caldav_url,
username=user.email,
password="", # Empty password - DAViCal uses X-Forwarded-User header
password="", # Empty password - server uses X-Forwarded-User header
timeout=self.timeout,
headers={
"X-Forwarded-User": user.email,
},
)
def ensure_user_exists(self, user) -> None:
"""
Ensure the user exists in DAViCal's database.
Creates the user if they don't exist.
"""
# Connect to shared calendars database (public schema)
default_db = settings.DATABASES["default"]
db_name = default_db.get("NAME", "calendars")
# Get password - handle SecretValue objects
password = default_db.get("PASSWORD", "pass")
if hasattr(password, "value"):
password = password.value
# Connect to calendars database
conn = psycopg.connect(
host=default_db.get("HOST", "localhost"),
port=default_db.get("PORT", 5432),
dbname=db_name,
user=default_db.get("USER", "pgroot"),
password=password,
)
try:
with conn.cursor() as cursor:
# Check if user exists (in public schema)
cursor.execute(
"SELECT user_no FROM usr WHERE lower(username) = lower(%s)",
[user.email],
)
if cursor.fetchone():
# User already exists
return
# Create user in DAViCal (public schema)
# Use email as username, password '*' means external auth
# Get user's full name or use email prefix
fullname = (
getattr(user, "full_name", None)
or getattr(user, "get_full_name", lambda: None)()
or user.email.split("@")[0]
)
cursor.execute(
"""
INSERT INTO usr (username, email, fullname, active, password)
VALUES (%s, %s, %s, true, '*')
ON CONFLICT (lower(username)) DO NOTHING
RETURNING user_no
""",
[user.email, user.email, fullname],
)
result = cursor.fetchone()
if result:
user_no = result[0]
logger.info(
"Created DAViCal user: %s (user_no: %s)", user.email, user_no
)
# Also create a principal record for the user (public schema)
# DAViCal needs both usr and principal records
# Principal type 1 is for users
type_id = 1
cursor.execute(
"""
INSERT INTO principal (type_id, user_no, displayname)
SELECT %s, %s, %s
WHERE NOT EXISTS (SELECT 1 FROM principal WHERE user_no = %s)
RETURNING principal_id
""",
[type_id, user_no, fullname, user_no],
)
principal_result = cursor.fetchone()
if principal_result:
logger.info(
"Created DAViCal principal: %s (principal_id: %s)",
user.email,
principal_result[0],
)
else:
logger.warning("User %s already exists in DAViCal", user.email)
conn.commit()
finally:
conn.close()
def create_calendar(self, user, calendar_name: str, calendar_id: str) -> str:
"""
Create a new calendar in DAViCal for the given user.
Returns the DAViCal path for the calendar.
Create a new calendar in CalDAV server for the given user.
Returns the CalDAV server path for the calendar.
"""
# Ensure user exists first
self.ensure_user_exists(user)
client = self._get_client(user)
principal = client.principal()
@@ -149,21 +63,23 @@ class DAViCalClient:
# Create calendar using caldav library
calendar = principal.make_calendar(name=calendar_name)
# DAViCal calendar path format: /caldav.php/{username}/{calendar_id}/
# CalDAV server calendar path format: /calendars/{username}/{calendar_id}/
# The caldav library returns a URL object, convert to string and extract path
calendar_url = str(calendar.url)
# Extract path from full URL
if calendar_url.startswith(self.base_url):
path = calendar_url[len(self.base_url) :]
else:
# Fallback: construct path manually based on DAViCal's structure
# DAViCal creates calendars with a specific path structure
path = f"/caldav.php/{user.email}/{calendar_id}/"
# Fallback: construct path manually based on standard CalDAV structure
# CalDAV servers typically create calendars under /calendars/{principal}/
path = f"/calendars/{user.email}/{calendar_id}/"
logger.info("Created calendar in DAViCal: %s at %s", calendar_name, path)
logger.info(
"Created calendar in CalDAV server: %s at %s", calendar_name, path
)
return path
except Exception as e:
logger.error("Failed to create calendar in DAViCal: %s", str(e))
logger.error("Failed to create calendar in CalDAV server: %s", str(e))
raise
def get_events(
@@ -177,8 +93,6 @@ class DAViCalClient:
Get events from a calendar within a time range.
Returns list of event dictionaries with parsed data.
"""
# Ensure user exists first
self.ensure_user_exists(user)
# Default to current month if no range specified
if start is None:
@@ -217,16 +131,14 @@ class DAViCalClient:
logger.warning("Calendar not found at path: %s", calendar_path)
return []
except Exception as e:
logger.error("Failed to get events from DAViCal: %s", str(e))
logger.error("Failed to get events from CalDAV server: %s", str(e))
raise
def create_event(self, user, calendar_path: str, event_data: dict) -> str:
"""
Create a new event in DAViCal.
Create a new event in CalDAV server.
Returns the event UID.
"""
# Ensure user exists first
self.ensure_user_exists(user)
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
@@ -260,18 +172,16 @@ class DAViCalClient:
elif hasattr(event, "vobject_instance"):
event_uid = event.vobject_instance.vevent.uid.value
logger.info("Created event in DAViCal: %s", event_uid)
logger.info("Created event in CalDAV server: %s", event_uid)
return event_uid
except Exception as e:
logger.error("Failed to create event in DAViCal: %s", str(e))
logger.error("Failed to create event in CalDAV server: %s", str(e))
raise
def update_event(
self, user, calendar_path: str, event_uid: str, event_data: dict
) -> None:
"""Update an existing event in DAViCal."""
# Ensure user exists first
self.ensure_user_exists(user)
"""Update an existing event in CalDAV server."""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
@@ -320,15 +230,13 @@ class DAViCalClient:
# Save the updated event
target_event.save()
logger.info("Updated event in DAViCal: %s", event_uid)
logger.info("Updated event in CalDAV server: %s", event_uid)
except Exception as e:
logger.error("Failed to update event in DAViCal: %s", str(e))
logger.error("Failed to update event in CalDAV server: %s", str(e))
raise
def delete_event(self, user, calendar_path: str, event_uid: str) -> None:
"""Delete an event from DAViCal."""
# Ensure user exists first
self.ensure_user_exists(user)
"""Delete an event from CalDAV server."""
client = self._get_client(user)
calendar_url = f"{self.base_url}{calendar_path}"
@@ -356,9 +264,9 @@ class DAViCalClient:
# Delete the event
target_event.delete()
logger.info("Deleted event from DAViCal: %s", event_uid)
logger.info("Deleted event from CalDAV server: %s", event_uid)
except Exception as e:
logger.error("Failed to delete event from DAViCal: %s", str(e))
logger.error("Failed to delete event from CalDAV server: %s", str(e))
raise
def _parse_event(self, event) -> Optional[dict]:
@@ -404,7 +312,7 @@ class CalendarService:
"""
def __init__(self):
self.davical = DAViCalClient()
self.caldav = CalDAVClient()
def create_default_calendar(self, user) -> Calendar:
"""
@@ -413,14 +321,14 @@ class CalendarService:
calendar_id = str(uuid4())
calendar_name = "Mon calendrier"
# Create calendar in DAViCal
davical_path = self.davical.create_calendar(user, calendar_name, calendar_id)
# Create calendar in CalDAV server
caldav_path = self.caldav.create_calendar(user, calendar_name, calendar_id)
# Create local Calendar record
calendar = Calendar.objects.create(
owner=user,
name=calendar_name,
davical_path=davical_path,
caldav_path=caldav_path,
is_default=True,
color="#3174ad",
)
@@ -433,14 +341,14 @@ class CalendarService:
"""
calendar_id = str(uuid4())
# Create calendar in DAViCal
davical_path = self.davical.create_calendar(user, name, calendar_id)
# Create calendar in CalDAV server
caldav_path = self.caldav.create_calendar(user, name, calendar_id)
# Create local Calendar record
calendar = Calendar.objects.create(
owner=user,
name=name,
davical_path=davical_path,
caldav_path=caldav_path,
is_default=False,
color=color,
)
@@ -460,18 +368,18 @@ class CalendarService:
Get events from a calendar.
Returns parsed event data.
"""
return self.davical.get_events(user, calendar.davical_path, start, end)
return self.caldav.get_events(user, calendar.caldav_path, start, end)
def create_event(self, user, calendar: Calendar, event_data: dict) -> str:
"""Create a new event."""
return self.davical.create_event(user, calendar.davical_path, event_data)
return self.caldav.create_event(user, calendar.caldav_path, event_data)
def update_event(
self, user, calendar: Calendar, event_uid: str, event_data: dict
) -> None:
"""Update an existing event."""
self.davical.update_event(user, calendar.davical_path, event_uid, event_data)
self.caldav.update_event(user, calendar.caldav_path, event_uid, event_data)
def delete_event(self, user, calendar: Calendar, event_uid: str) -> None:
"""Delete an event."""
self.davical.delete_event(user, calendar.davical_path, event_uid)
self.caldav.delete_event(user, calendar.caldav_path, event_uid)

View File

@@ -27,8 +27,8 @@ def provision_default_calendar(sender, instance, created, **kwargs):
if instance.calendars.filter(is_default=True).exists():
return
# Skip calendar creation if DAViCal is not configured
if not getattr(settings, "DAVICAL_URL", None):
# Skip calendar creation if CalDAV server is not configured
if not settings.CALDAV_URL:
return
try:
@@ -36,7 +36,7 @@ def provision_default_calendar(sender, instance, created, **kwargs):
service.create_default_calendar(instance)
logger.info("Created default calendar for user %s", instance.email)
except Exception as e:
# In tests, DAViCal tables don't exist, so fail silently
# In tests, CalDAV server may not be available, so fail silently
# Check if it's a database error that suggests we're in tests
error_str = str(e).lower()
if "does not exist" in error_str or "relation" in error_str:

View File

@@ -500,7 +500,7 @@ def test_authentication_session_tokens(
status=200,
)
with django_assert_num_queries(27):
with django_assert_num_queries(7):
user = klass.authenticate(
request,
code="test-code",

View File

@@ -19,23 +19,32 @@ VIA = [USER, TEAM]
@pytest.fixture(autouse=True)
def truncate_davical_tables(django_db_setup, django_db_blocker):
"""Fixture to truncate DAViCal tables at the start of each test.
def truncate_caldav_tables(django_db_setup, django_db_blocker):
"""Fixture to truncate CalDAV server tables at the start of each test.
DAViCal tables are created by the DAViCal container migrations, not Django.
CalDAV server tables are created by the CalDAV server container migrations, not Django.
We just truncate them to ensure clean state for each test.
"""
with django_db_blocker.unblock():
with connection.cursor() as cursor:
# Truncate DAViCal tables if they exist (created by DAViCal container)
# Truncate CalDAV server tables if they exist (created by CalDAV server container)
cursor.execute("""
DO $$
BEGIN
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'principal') THEN
TRUNCATE TABLE principal CASCADE;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'principals') THEN
TRUNCATE TABLE principals CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'usr') THEN
TRUNCATE TABLE usr CASCADE;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'users') THEN
TRUNCATE TABLE users CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendars') THEN
TRUNCATE TABLE calendars CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendarinstances') THEN
TRUNCATE TABLE calendarinstances CASCADE;
END IF;
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'calendarobjects') THEN
TRUNCATE TABLE calendarobjects CASCADE;
END IF;
END $$;
""")

View File

@@ -148,11 +148,14 @@ def test_api_users_list_throttling_authenticated(settings):
assert response.status_code == 429
def test_api_users_list_query_email():
def test_api_users_list_query_email(settings):
"""
Authenticated users should be able to list users and filter by email.
Only exact email matches are returned (case-insensitive).
"""
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "9999/minute"
user = factories.UserFactory()
client = APIClient()

View File

@@ -0,0 +1,307 @@
"""Tests for CalDAV proxy view."""
from xml.etree import ElementTree as ET
from django.conf import settings
import pytest
import responses
from rest_framework.status import (
HTTP_200_OK,
HTTP_207_MULTI_STATUS,
HTTP_401_UNAUTHORIZED,
)
from rest_framework.test import APIClient
from core import factories
@pytest.mark.django_db
class TestCalDAVProxy:
"""Tests for CalDAVProxyView."""
def test_proxy_requires_authentication(self):
"""Test that unauthenticated requests return 401."""
client = APIClient()
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
assert response.status_code == HTTP_401_UNAUTHORIZED
@responses.activate
def test_proxy_forwards_headers_correctly(self):
"""Test that proxy forwards X-Forwarded-User headers."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
# Mock CalDAV server response
caldav_url = settings.CALDAV_URL
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
)
)
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
# Verify request was made to CalDAV server
assert len(responses.calls) == 1
request = responses.calls[0].request
# Verify headers were forwarded
assert request.headers["X-Forwarded-User"] == user.email
assert request.headers["X-Forwarded-Host"] is not None
assert request.headers["X-Forwarded-Proto"] == "http"
@responses.activate
def test_proxy_ignores_client_sent_x_forwarded_user_header(self):
"""Test that proxy ignores and overwrites any X-Forwarded-User header sent by client.
This is a security test to ensure that hostile clients cannot impersonate other users
by sending a malicious X-Forwarded-User header. The proxy should always use the
authenticated Django user's email, not any header value sent by the client.
"""
user = factories.UserFactory(email="legitimate@example.com")
client = APIClient()
client.force_login(user)
# Mock CalDAV server response
caldav_url = settings.CALDAV_URL
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
)
)
# Try to send a malicious X-Forwarded-User header as if we were another user
malicious_email = "attacker@example.com"
response = client.generic(
"PROPFIND",
"/api/v1.0/caldav/",
HTTP_X_FORWARDED_USER=malicious_email,
)
# Verify request was made to CalDAV server
assert len(responses.calls) == 1
request = responses.calls[0].request
# Verify that the X-Forwarded-User header uses the authenticated user's email,
# NOT the malicious header value sent by the client
assert request.headers["X-Forwarded-User"] == user.email, (
f"Expected X-Forwarded-User to be {user.email} (authenticated user), "
f"but got {request.headers.get('X-Forwarded-User')}. "
f"This indicates a security vulnerability - client-sent headers are being trusted!"
)
assert request.headers["X-Forwarded-User"] != malicious_email, (
"X-Forwarded-User should NOT use client-sent header value"
)
@pytest.mark.skipif(
not settings.CALDAV_URL,
reason="CalDAV server URL not configured - integration test requires real server",
)
def test_proxy_propfind_response_contains_prefixed_urls(self):
"""Integration test: PROPFIND responses from sabre/dav should contain URLs with proxy prefix.
This test verifies that sabre/dav's BaseUriPlugin correctly uses X-Forwarded-Prefix
to generate URLs with the proxy prefix. It requires the CalDAV server to be running.
Note: This test does NOT use @responses.activate as it needs to hit the real server.
"""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
# Make actual request to CalDAV server through proxy
# The server should use X-Forwarded-Prefix to generate URLs
propfind_body = '<?xml version="1.0"?><propfind xmlns="DAV:"><prop><resourcetype/></prop></propfind>'
response = client.generic(
"PROPFIND",
"/api/v1.0/caldav/",
data=propfind_body,
content_type="application/xml",
)
assert response.status_code == HTTP_207_MULTI_STATUS, (
f"Expected 207 Multi-Status, got {response.status_code}: {response.content.decode('utf-8', errors='ignore')}"
)
# Parse the response XML
root = ET.fromstring(response.content)
# Find all href elements
href_elems = root.findall(".//{DAV:}href")
assert len(href_elems) > 0, "PROPFIND response should contain href elements"
# Verify all URLs that start with /principals/ or /calendars/ include the proxy prefix
# This verifies that sabre/dav's BaseUriPlugin is working correctly
for href_elem in href_elems:
href = href_elem.text
if href and (
href.startswith("/principals/") or href.startswith("/calendars/")
):
assert href.startswith("/api/v1.0/caldav/"), (
f"Expected URL to start with /api/v1.0/caldav/, got {href}. "
f"This indicates sabre/dav BaseUriPlugin is not using X-Forwarded-Prefix correctly. "
f"Full response: {response.content.decode('utf-8', errors='ignore')}"
)
@responses.activate
def test_proxy_passes_through_calendar_urls(self):
"""Test that calendar URLs in PROPFIND responses are passed through unchanged.
Since we removed URL rewriting from the proxy, sabre/dav should generate
URLs with the correct prefix. This test verifies the proxy passes responses through.
"""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
# Mock CalDAV server PROPFIND response with calendar URL that already has prefix
# (sabre/dav should generate URLs with prefix when X-Forwarded-Prefix is set)
caldav_url = settings.CALDAV_URL
propfind_xml = """<?xml version="1.0"?>
<multistatus xmlns="DAV:">
<response>
<href>/api/v1.0/caldav/calendars/test@example.com/calendar-id/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<calendar xmlns="urn:ietf:params:xml:ns:caldav"/>
</resourcetype>
</prop>
</propstat>
</response>
</multistatus>"""
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
status=HTTP_207_MULTI_STATUS,
body=propfind_xml,
headers={"Content-Type": "application/xml"},
)
)
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
assert response.status_code == HTTP_207_MULTI_STATUS
# Parse the response XML
root = ET.fromstring(response.content)
# Find the href element
href_elem = root.find(".//{DAV:}href")
assert href_elem is not None
# Verify the URL is passed through unchanged (sabre/dav should generate it with prefix)
href = href_elem.text
assert href == "/api/v1.0/caldav/calendars/test@example.com/calendar-id/", (
f"Expected URL to be passed through unchanged, got {href}"
)
@responses.activate
def test_proxy_passes_through_namespaced_href_attributes(self):
"""Test that namespaced href attributes (D:href) are passed through unchanged.
Since we removed URL rewriting from the proxy, sabre/dav should generate
URLs with the correct prefix. This test verifies the proxy passes responses through.
"""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
# Mock CalDAV server PROPFIND response with D:href that already has prefix
# (sabre/dav should generate URLs with prefix when X-Forwarded-Prefix is set)
caldav_url = settings.CALDAV_URL
propfind_xml = """<?xml version="1.0"?>
<multistatus xmlns="DAV:" xmlns:D="DAV:">
<response>
<D:href>/api/v1.0/caldav/principals/test@example.com/</D:href>
<propstat>
<prop>
<resourcetype>
<principal/>
</resourcetype>
</prop>
</propstat>
</response>
</multistatus>"""
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
status=HTTP_207_MULTI_STATUS,
body=propfind_xml,
headers={"Content-Type": "application/xml"},
)
)
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
assert response.status_code == HTTP_207_MULTI_STATUS
# Parse the response XML
root = ET.fromstring(response.content)
# Find the D:href element (namespaced)
href_elem = root.find(".//{DAV:}href")
assert href_elem is not None
# Verify the URL is passed through unchanged (sabre/dav should generate it with prefix)
href = href_elem.text
assert href == "/api/v1.0/caldav/principals/test@example.com/", (
f"Expected URL to be passed through unchanged, got {href}"
)
@responses.activate
def test_proxy_forwards_path_correctly(self):
"""Test that proxy forwards the path correctly to CalDAV server."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
caldav_url = settings.CALDAV_URL
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/principals/test@example.com/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
)
)
# Request a specific path
response = client.generic(
"PROPFIND", "/api/v1.0/caldav/principals/test@example.com/"
)
# Verify the request was made to the correct URL
assert len(responses.calls) == 1
request = responses.calls[0].request
assert (
request.url == f"{caldav_url}/api/v1.0/caldav/principals/test@example.com/"
)
@responses.activate
def test_proxy_handles_options_request(self):
"""Test that OPTIONS requests are handled for CORS."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
response = client.options("/api/v1.0/caldav/")
assert response.status_code == HTTP_200_OK
assert "Access-Control-Allow-Methods" in response
assert "PROPFIND" in response["Access-Control-Allow-Methods"]

View File

@@ -1,4 +1,4 @@
"""Tests for CalDAV service integration with DAViCal."""
"""Tests for CalDAV service integration."""
from unittest.mock import Mock, patch
@@ -7,17 +7,17 @@ from django.conf import settings
import pytest
from core import factories
from core.services.caldav_service import CalendarService, DAViCalClient
from core.services.caldav_service import CalDAVClient, CalendarService
@pytest.mark.django_db
class TestDAViCalClient:
"""Tests for DAViCalClient authentication and communication."""
class TestCalDAVClient:
"""Tests for CalDAVClient authentication and communication."""
def test_get_client_sends_x_forwarded_user_header(self):
"""Test that DAVClient is configured with X-Forwarded-User header."""
user = factories.UserFactory(email="test@example.com")
client = DAViCalClient()
client = CalDAVClient()
dav_client = client._get_client(user)
@@ -33,16 +33,13 @@ class TestDAViCalClient:
assert dav_client.headers["X-Forwarded-User"] == user.email
@pytest.mark.skipif(
not getattr(settings, "DAVICAL_URL", None),
reason="DAViCal URL not configured",
not settings.CALDAV_URL,
reason="CalDAV server URL not configured",
)
def test_create_calendar_authenticates_with_davical(self):
"""Test that calendar creation authenticates successfully with DAViCal."""
def test_create_calendar_authenticates_with_caldav_server(self):
"""Test that calendar creation authenticates successfully with CalDAV server."""
user = factories.UserFactory(email="test@example.com")
client = DAViCalClient()
# Ensure user exists in DAViCal
client.ensure_user_exists(user)
client = CalDAVClient()
# Try to create a calendar - this should authenticate successfully
calendar_path = client.create_calendar(
@@ -51,11 +48,14 @@ class TestDAViCalClient:
# Verify calendar path was returned
assert calendar_path is not None
assert calendar_path.startswith("/caldav.php/")
assert user.email in calendar_path
# Email may be URL-encoded in the path (e.g., test%40example.com)
assert (
user.email.replace("@", "%40") in calendar_path
or user.email in calendar_path
)
def test_calendar_service_creates_calendar(self):
"""Test that CalendarService can create a calendar through DAViCal."""
"""Test that CalendarService can create a calendar through CalDAV server."""
user = factories.UserFactory(email="test@example.com")
service = CalendarService()
@@ -67,5 +67,4 @@ class TestDAViCalClient:
assert calendar.owner == user
assert calendar.name == "My Calendar"
assert calendar.color == "#ff0000"
assert calendar.davical_path is not None
assert calendar.davical_path.startswith("/caldav.php/")
assert calendar.caldav_path is not None