From 3ed52ca5d01a88eb27e26d87027cd43f575d60b6 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Sun, 11 Jan 2026 03:52:43 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(scheduling)=20add=20callback=20from?= =?UTF-8?q?=20caldav=20to=20django=20for=20imip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/pytest | 1 + compose.yaml | 1 - docker/sabredav/Dockerfile | 9 + docker/sabredav/sabredav.conf | 11 +- docker/sabredav/server.php | 40 ++- docker/sabredav/src/ApiKeyAuthBackend.php | 86 ++++++ .../sabredav/src/HttpCallbackIMipPlugin.php | 151 +++++++++++ env.d/development/backend.defaults | 2 + env.d/development/caldav.defaults | 4 +- src/backend/calendars/settings.py | 10 + src/backend/core/api/viewsets_caldav.py | 74 +++++- src/backend/core/services/caldav_service.py | 28 +- .../core/tests/test_caldav_scheduling.py | 246 ++++++++++++++++++ src/backend/core/tests/test_caldav_service.py | 6 +- src/backend/core/urls.py | 8 +- 15 files changed, 636 insertions(+), 41 deletions(-) create mode 100644 docker/sabredav/src/ApiKeyAuthBackend.php create mode 100644 docker/sabredav/src/HttpCallbackIMipPlugin.php create mode 100644 src/backend/core/tests/test_caldav_scheduling.py diff --git a/bin/pytest b/bin/pytest index bd3b0d1..e713c8a 100755 --- a/bin/pytest +++ b/bin/pytest @@ -3,6 +3,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" _dc_run \ + --name backend-test \ -e DJANGO_CONFIGURATION=Test \ backend-dev \ pytest "$@" diff --git a/compose.yaml b/compose.yaml index b89bf89..ffe2a46 100644 --- a/compose.yaml +++ b/compose.yaml @@ -36,7 +36,6 @@ services: environment: - PYLINTHOME=/app/.pylint.d - DJANGO_CONFIGURATION=Development - - CALDAV_URL=http://caldav:80 env_file: - env.d/development/backend.defaults - env.d/development/backend.local diff --git a/docker/sabredav/Dockerfile b/docker/sabredav/Dockerfile index e49f714..e841569 100644 --- a/docker/sabredav/Dockerfile +++ b/docker/sabredav/Dockerfile @@ -44,6 +44,15 @@ RUN a2enmod rewrite headers \ && a2ensite sabredav \ && chmod +x /usr/local/bin/init-database.sh +# Configure PHP error logging to stderr for Docker logs +# This ensures all error_log() calls and PHP errors are visible in docker logs +# display_errors = Off prevents errors from appearing in HTTP responses (security/UX) +# but errors are still logged to stderr (Docker logs) via log_errors = On +RUN echo "log_errors = On" >> /usr/local/etc/php/conf.d/error-logging.ini \ + && echo "error_log = /proc/self/fd/2" >> /usr/local/etc/php/conf.d/error-logging.ini \ + && echo "display_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini \ + && echo "display_startup_errors = Off" >> /usr/local/etc/php/conf.d/error-logging.ini + # Set permissions RUN chown -R www-data:www-data /var/www/sabredav \ && chmod -R 755 /var/www/sabredav diff --git a/docker/sabredav/sabredav.conf b/docker/sabredav/sabredav.conf index deef0d3..75ed07d 100644 --- a/docker/sabredav/sabredav.conf +++ b/docker/sabredav/sabredav.conf @@ -8,12 +8,6 @@ Options -Indexes +FollowSymLinks - # Set REMOTE_USER from X-Forwarded-User header (set by Django proxy) - # This allows sabre/dav to use Apache auth backend - - RequestHeader set REMOTE_USER %{HTTP:X-Forwarded-User}e env=HTTP_X_FORWARDED_USER - - # Rewrite rules for CalDAV RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f @@ -23,6 +17,7 @@ # Well-known CalDAV discovery RewriteRule ^\.well-known/caldav / [R=301,L] - ErrorLog ${APACHE_LOG_DIR}/sabredav_error.log - CustomLog ${APACHE_LOG_DIR}/sabredav_access.log combined + # Write errors to stderr for Docker logs + ErrorLog /proc/self/fd/2 + # CustomLog /proc/self/fd/1 combined diff --git a/docker/sabredav/server.php b/docker/sabredav/server.php index 9065db2..eac0bfc 100644 --- a/docker/sabredav/server.php +++ b/docker/sabredav/server.php @@ -1,7 +1,7 @@ addPlugin(new CardDAV\Plugin()); $server->addPlugin(new DAVACL\Plugin()); $server->addPlugin(new DAV\Browser\Plugin()); +// Add custom IMipPlugin that forwards scheduling messages via HTTP callback +// This MUST be added BEFORE the Schedule\Plugin so that Schedule\Plugin finds it +// The callback URL must be provided per-request via X-CalDAV-Callback-URL header +$callbackApiKey = getenv('CALDAV_INBOUND_API_KEY'); +if (!$callbackApiKey) { + error_log("[sabre/dav] CALDAV_INBOUND_API_KEY environment variable is required for scheduling callback"); + exit(1); +} +$imipPlugin = new HttpCallbackIMipPlugin($callbackApiKey); +$server->addPlugin($imipPlugin); + +// Add CalDAV scheduling support +// See https://sabre.io/dav/scheduling/ +// The Schedule\Plugin will automatically find and use the IMipPlugin we just added +// It looks for plugins that implement CalDAV\Schedule\IMipPlugin interface +$schedulePlugin = new CalDAV\Schedule\Plugin(); +$server->addPlugin($schedulePlugin); + +// error_log("[sabre/dav] Starting server"); + // Start server $server->start(); diff --git a/docker/sabredav/src/ApiKeyAuthBackend.php b/docker/sabredav/src/ApiKeyAuthBackend.php new file mode 100644 index 0000000..42f9174 --- /dev/null +++ b/docker/sabredav/src/ApiKeyAuthBackend.php @@ -0,0 +1,86 @@ +apiKey = $apiKey; + } + + /** + * When this method is called, the backend must check if authentication was + * successful. + * + * The returned value must be one of the following: + * + * [true, "principals/username"] - authentication was successful, and a principal url is returned. + * [false, "reason for failure"] - authentication failed, reason is optional + * [null, null] - The backend cannot determine. The next backend will be queried. + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return array + */ + public function check(RequestInterface $request, ResponseInterface $response) + { + // Get user from X-Forwarded-User header (required) + $xForwardedUser = $request->getHeader('X-Forwarded-User'); + if (!$xForwardedUser) { + return [false, 'X-Forwarded-User header is required']; + } + + // API key is required + $apiKeyHeader = $request->getHeader('X-Api-Key'); + if (!$apiKeyHeader) { + return [false, 'X-Api-Key header is required']; + } + + // Validate API key + if ($apiKeyHeader !== $this->apiKey) { + return [false, 'Invalid API key']; + } + + // Authentication successful + return [true, 'principals/' . $xForwardedUser]; + } + + /** + * This method is called when a user could not be authenticated. + * + * This gives us a chance to set up authentication challenges (for example HTTP auth). + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return void + */ + public function challenge(RequestInterface $request, ResponseInterface $response) + { + // We don't use HTTP Basic/Digest auth, so no challenge needed + // The error message from check() will be returned + } +} diff --git a/docker/sabredav/src/HttpCallbackIMipPlugin.php b/docker/sabredav/src/HttpCallbackIMipPlugin.php new file mode 100644 index 0000000..3d80890 --- /dev/null +++ b/docker/sabredav/src/HttpCallbackIMipPlugin.php @@ -0,0 +1,151 @@ +apiKey = $apiKey; + } + + /** + * Initialize the plugin. + * + * @param Server $server + * @return void + */ + public function initialize(Server $server) + { + parent::initialize($server); + $this->server = $server; + } + + /** + * Event handler for the 'schedule' event. + * + * This overrides the parent's schedule() method to forward messages via HTTP callback + * instead of sending emails via PHP's mail() function. + * + * @param Message $iTipMessage The iTip message + * @return void + */ + public function schedule(Message $iTipMessage) + { + // Not sending any messages if the system considers the update insignificant. + if (!$iTipMessage->significantChange) { + if (!$iTipMessage->scheduleStatus) { + $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant delivery'; + } + return; + } + + // Only handle mailto: recipients (external attendees) + if ('mailto' !== parse_url($iTipMessage->sender, PHP_URL_SCHEME)) { + return; + } + + if ('mailto' !== parse_url($iTipMessage->recipient, PHP_URL_SCHEME)) { + return; + } + + // Get callback URL from the HTTP request header (required) + if (!$this->server || !$this->server->httpRequest) { + $iTipMessage->scheduleStatus = '5.4;No HTTP request available for callback URL'; + return; + } + + $callbackUrl = $this->server->httpRequest->getHeader('X-CalDAV-Callback-URL'); + if (!$callbackUrl) { + error_log("[HttpCallbackIMipPlugin] ERROR: X-CalDAV-Callback-URL header is required"); + $iTipMessage->scheduleStatus = '5.4;X-CalDAV-Callback-URL header is required'; + return; + } + + // Ensure URL ends with trailing slash for Django's APPEND_SLASH middleware + $callbackUrl = rtrim($callbackUrl, '/') . '/'; + + // Serialize the iCalendar message + $vcalendar = $iTipMessage->message ? $iTipMessage->message->serialize() : ''; + + // Prepare headers + // Trim API key to remove any whitespace from environment variable + $apiKey = trim($this->apiKey); + $headers = [ + 'Content-Type: text/calendar', + 'X-Api-Key: ' . $apiKey, + 'X-CalDAV-Sender: ' . $iTipMessage->sender, + 'X-CalDAV-Recipient: ' . $iTipMessage->recipient, + 'X-CalDAV-Method: ' . $iTipMessage->method, + ]; + + // Make HTTP POST request to Django callback endpoint + $ch = curl_init($callbackUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => $vcalendar, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError) { + error_log(sprintf( + "[HttpCallbackIMipPlugin] ERROR: cURL failed: %s", + $curlError + )); + $iTipMessage->scheduleStatus = '5.4;Failed to forward scheduling message via HTTP callback'; + return; + } + + if ($httpCode >= 400) { + error_log(sprintf( + "[HttpCallbackIMipPlugin] ERROR: HTTP %d - %s", + $httpCode, + substr($response, 0, 200) + )); + $iTipMessage->scheduleStatus = '5.4;HTTP callback returned error: ' . $httpCode; + return; + } + + // Success + $iTipMessage->scheduleStatus = '1.1;Scheduling message forwarded via HTTP callback'; + } +} diff --git a/env.d/development/backend.defaults b/env.d/development/backend.defaults index 2a5fd1f..37d58ee 100644 --- a/env.d/development/backend.defaults +++ b/env.d/development/backend.defaults @@ -48,6 +48,8 @@ OIDC_RS_ALLOWED_AUDIENCES="" # CalDAV Server CALDAV_URL=http://caldav:80 +CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production +CALDAV_INBOUND_API_KEY=changeme-inbound-in-production # Frontend FRONTEND_THEME=default diff --git a/env.d/development/caldav.defaults b/env.d/development/caldav.defaults index c3fab37..d44d66e 100644 --- a/env.d/development/caldav.defaults +++ b/env.d/development/caldav.defaults @@ -3,4 +3,6 @@ PGPORT=5432 PGDATABASE=calendars PGUSER=pgroot PGPASSWORD=pass -CALENDARS_BASE_URI=/api/v1.0/caldav/ \ No newline at end of file +CALENDARS_BASE_URI=/api/v1.0/caldav/ +CALDAV_INBOUND_API_KEY=changeme-inbound-in-production +CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production \ No newline at end of file diff --git a/src/backend/calendars/settings.py b/src/backend/calendars/settings.py index aca0a0c..4820569 100755 --- a/src/backend/calendars/settings.py +++ b/src/backend/calendars/settings.py @@ -72,6 +72,16 @@ class Base(Configuration): "http://caldav:80", environ_name="CALDAV_URL", environ_prefix=None ) + # CalDAV API keys for bidirectional authentication + # INBOUND: API key for authenticating requests FROM CalDAV server TO Django + CALDAV_INBOUND_API_KEY = values.Value( + None, environ_name="CALDAV_INBOUND_API_KEY", environ_prefix=None + ) + # OUTBOUND: API key for authenticating requests FROM Django TO CalDAV server + CALDAV_OUTBOUND_API_KEY = values.Value( + None, environ_name="CALDAV_OUTBOUND_API_KEY", environ_prefix=None + ) + # Security ALLOWED_HOSTS = values.ListValue([]) SECRET_KEY = SecretFileValue(None) diff --git a/src/backend/core/api/viewsets_caldav.py b/src/backend/core/api/viewsets_caldav.py index 26eb358..bb34dd2 100644 --- a/src/backend/core/api/viewsets_caldav.py +++ b/src/backend/core/api/viewsets_caldav.py @@ -1,6 +1,7 @@ """CalDAV proxy views for forwarding requests to CalDAV server.""" import logging +import secrets from django.conf import settings from django.http import HttpResponse @@ -61,7 +62,7 @@ class CalDAVProxyView(View): target_url = f"{caldav_url}{base_uri_path}/" # Prepare headers for CalDAV server - # CalDAV server Apache backend reads REMOTE_USER, which we set via X-Forwarded-User + # CalDAV server uses custom auth backend that requires X-Forwarded-User header and API key headers = { "Content-Type": request.content_type or "application/xml", "X-Forwarded-User": user_principal, @@ -70,11 +71,18 @@ class CalDAVProxyView(View): "X-Forwarded-Proto": request.scheme, } - # 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 - # CalDAV server converts X-Forwarded-User to REMOTE_USER - auth = (user_principal, "") + # API key is required for authentication + outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY + if not outbound_api_key: + logger.error("CALDAV_OUTBOUND_API_KEY is not configured") + return HttpResponse( + status=500, content="CalDAV authentication not configured" + ) + + headers["X-Api-Key"] = outbound_api_key + + # No Basic Auth - our custom backend uses X-Forwarded-User header and API key + auth = None # Copy relevant headers from the original request if "HTTP_DEPTH" in request.META: @@ -91,8 +99,7 @@ class CalDAVProxyView(View): try: # Forward the request to CalDAV server - # Use HTTP Basic Auth with username (email) and empty password - # CalDAV server will authenticate based on X-Forwarded-User header (converted to REMOTE_USER) + # CalDAV server authenticates via X-Forwarded-User header and API key logger.debug( "Forwarding %s request to CalDAV server: %s (user: %s)", request.method, @@ -169,7 +176,56 @@ class CalDAVDiscoveryView(View): # Clients need to discover the CalDAV URL before authenticating # Return redirect to CalDAV server base URL - caldav_base_url = f"/api/v1.0/caldav/" + caldav_base_url = f"/api/{settings.API_VERSION}/caldav/" response = HttpResponse(status=301) response["Location"] = caldav_base_url return response + + +@method_decorator(csrf_exempt, name="dispatch") +class CalDAVSchedulingCallbackView(View): + """ + Endpoint for receiving CalDAV scheduling messages (iMip) from sabre/dav. + + This endpoint receives scheduling messages (invites, responses, cancellations) + from the CalDAV server and processes them. Authentication is via API key. + + See: https://sabre.io/dav/scheduling/ + """ + + def dispatch(self, request, *args, **kwargs): + """Handle scheduling messages from CalDAV server.""" + # Authenticate via API key + api_key = request.headers.get("X-Api-Key", "").strip() + expected_key = settings.CALDAV_INBOUND_API_KEY + + if not expected_key or not secrets.compare_digest(api_key, expected_key): + logger.warning( + "CalDAV scheduling callback request with invalid API key. " + "Expected: %s..., Got: %s...", + expected_key[:10] if expected_key else "None", + api_key[:10] if api_key else "None", + ) + return HttpResponse(status=401) + + # Extract headers + sender = request.headers.get("X-CalDAV-Sender", "") + recipient = request.headers.get("X-CalDAV-Recipient", "") + method = request.headers.get("X-CalDAV-Method", "") + + # For now, just log the scheduling message + logger.info( + "Received CalDAV scheduling callback: %s -> %s (method: %s)", + sender, + recipient, + method, + ) + + # Log message body (first 500 chars) + if request.body: + body_preview = request.body[:500].decode("utf-8", errors="ignore") + logger.info("Scheduling message body (first 500 chars): %s", body_preview) + + # TODO: Process the scheduling message (send email, update calendar, etc.) + # For now, just return success + return HttpResponse(status=200, content_type="text/plain") diff --git a/src/backend/core/services/caldav_service.py b/src/backend/core/services/caldav_service.py index c6589ff..ecfb2d2 100644 --- a/src/backend/core/services/caldav_service.py +++ b/src/backend/core/services/caldav_service.py @@ -30,10 +30,8 @@ class CalDAVClient: """ Get a CalDAV client for the given user. - 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. + The CalDAV server requires API key authentication via Authorization header + and X-Forwarded-User header for user identification. """ # 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 @@ -41,14 +39,26 @@ class CalDAVClient: base_uri_clean = self.base_uri_path.rstrip("/") caldav_url = f"{base_url_clean}{base_uri_clean}/" + # Prepare headers + # API key is required for authentication + headers = { + "X-Forwarded-User": user.email, + } + + outbound_api_key = settings.CALDAV_OUTBOUND_API_KEY + if not outbound_api_key: + raise ValueError("CALDAV_OUTBOUND_API_KEY is not configured") + + headers["X-Api-Key"] = outbound_api_key + + # No username/password needed - authentication is via API key and X-Forwarded-User header + # Pass None to prevent the caldav library from trying Basic auth return DAVClient( url=caldav_url, - username=user.email, - password="", # Empty password - server uses X-Forwarded-User header + username=None, + password=None, timeout=self.timeout, - headers={ - "X-Forwarded-User": user.email, - }, + headers=headers, ) def create_calendar(self, user, calendar_name: str, calendar_id: str) -> str: diff --git a/src/backend/core/tests/test_caldav_scheduling.py b/src/backend/core/tests/test_caldav_scheduling.py new file mode 100644 index 0000000..c22eeb0 --- /dev/null +++ b/src/backend/core/tests/test_caldav_scheduling.py @@ -0,0 +1,246 @@ +"""Tests for CalDAV scheduling callback integration.""" + +import http.server +import logging +import secrets +import socket +import threading +import time +from datetime import datetime, timedelta + +from django.conf import settings + +import pytest + +from caldav.lib.error import NotFoundError +from core import factories +from core.services.caldav_service import CalendarService + +logger = logging.getLogger(__name__) + + +class CallbackHandler(http.server.BaseHTTPRequestHandler): + """HTTP request handler for capturing CalDAV scheduling callbacks in tests.""" + + def __init__(self, callback_data, *args, **kwargs): + self.callback_data = callback_data + super().__init__(*args, **kwargs) + + def do_POST(self): + """Handle POST requests (scheduling callbacks).""" + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) if content_length > 0 else b"" + + # Store callback data + self.callback_data["called"] = True + self.callback_data["request_data"] = { + "headers": dict(self.headers), + "body": body.decode("utf-8", errors="ignore") if body else "", + } + + # Send success response + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(b"OK") + + def log_message(self, format, *args): + """Suppress default logging.""" + pass + + +def create_test_server() -> tuple: + """Create a test HTTP server that captures callbacks. + + Returns: + Tuple of (server, port, callback_data) + """ + callback_data = {"called": False, "request_data": None} + + def handler_factory(*args, **kwargs): + return CallbackHandler(callback_data, *args, **kwargs) + + # Use fixed port 8001 - accessible from other Docker containers + port = 8001 + + # Create server with SO_REUSEADDR to allow quick port reuse + server = http.server.HTTPServer(("0.0.0.0", port), handler_factory) + server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + actual_port = server.server_address[1] + + return server, actual_port, callback_data + + +@pytest.mark.django_db +class TestCalDAVScheduling: + """Tests for CalDAV scheduling callback when creating events with attendees.""" + + @pytest.mark.skipif( + not settings.CALDAV_URL, + reason="CalDAV server URL not configured - integration test requires real server", + ) + def test_scheduling_callback_received_when_creating_event_with_attendee(self): + """Test that creating an event with an attendee triggers scheduling callback. + + This test verifies that when an event is created with an attendee via CalDAV, + the HttpCallbackIMipPlugin sends a scheduling message to the Django callback endpoint. + + The test starts a local HTTP server to receive the callback, and passes the server URL + to the CalDAV server via the X-CalDAV-Callback-URL header. + """ + # Create users: organizer + # Note: attendee should be external (not in CalDAV server) to trigger scheduling + organizer = factories.UserFactory(email="organizer@example.com") + + # Create calendar for organizer + service = CalendarService() + calendar = service.create_calendar( + organizer, name="Test Calendar", color="#ff0000" + ) + + # Start test HTTP server to receive callbacks + # Use fixed port 8001 - accessible from other Docker containers + server, port, callback_data = create_test_server() + + # Start server in a separate thread + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + # Give the server a moment to start listening + time.sleep(0.5) + + # Verify server is actually listening + test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + test_socket.connect(("127.0.0.1", port)) + test_socket.close() + except Exception as e: + pytest.fail(f"Test server failed to start on port {port}: {e}") + + # Use the named test container hostname + # The test container is created with --name backend-test in bin/pytest + # Docker Compose networking allows containers to reach each other by name + callback_url = f"http://backend-test:{port}/" + + try: + # Create an event with an attendee + client = service.caldav._get_client(organizer) + calendar_url = f"{settings.CALDAV_URL}{calendar.caldav_path}" + + # Add custom callback URL header to the client + # The CalDAV server will use this URL for the callback + client.headers["X-CalDAV-Callback-URL"] = callback_url + + try: + caldav_calendar = client.calendar(url=calendar_url) + + # Create event with attendee using iCalendar format + # We need to create the event with attendees to trigger scheduling + # Note: sabre/dav's scheduling plugin only sends messages for external attendees + # (attendees that don't have a principal in the same CalDAV server) + dtstart = datetime.now() + timedelta(days=1) + dtend = dtstart + timedelta(hours=1) + + # Use a clearly external attendee email (not in the CalDAV server) + external_attendee = "external-attendee@external-domain.com" + + # Create iCalendar event with attendee + ical_content = f"""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test Client//EN +BEGIN:VEVENT +UID:test-event-{datetime.now().timestamp()} +DTSTART:{dtstart.strftime("%Y%m%dT%H%M%SZ")} +DTEND:{dtend.strftime("%Y%m%dT%H%M%SZ")} +SUMMARY:Test Event with Attendee +ORGANIZER;CN=Organizer:mailto:{organizer.email} +ATTENDEE;CN=External Attendee;RSVP=TRUE:mailto:{external_attendee} +END:VEVENT +END:VCALENDAR""" + + # Save event to trigger scheduling + event = caldav_calendar.save_event(ical_content) + + # Give the callback a moment to be called (scheduling may be async) + # sabre/dav processes scheduling synchronously during the request + time.sleep(2) + + # Verify callback was called + assert callback_data["called"], ( + "Scheduling callback was not called when creating event with attendee. " + "This may indicate that sabre/dav's scheduling plugin is not working correctly. " + "Check CalDAV server logs for scheduling errors." + ) + + # Verify callback request details + request_data = callback_data["request_data"] + assert request_data is not None + + # Verify API key authentication + api_key = request_data["headers"].get("X-Api-Key", "") + expected_key = settings.CALDAV_INBOUND_API_KEY + assert expected_key and secrets.compare_digest(api_key, expected_key), ( + f"Callback request missing or invalid X-Api-Key header. " + f"Expected: {expected_key[:10]}..., Got: {api_key[:10] if api_key else 'None'}..." + ) + + # Verify scheduling headers + assert "X-CalDAV-Sender" in request_data["headers"], ( + "Missing X-CalDAV-Sender header" + ) + assert "X-CalDAV-Recipient" in request_data["headers"], ( + "Missing X-CalDAV-Recipient header" + ) + assert "X-CalDAV-Method" in request_data["headers"], ( + "Missing X-CalDAV-Method header" + ) + + # Verify sender is the organizer + sender = request_data["headers"]["X-CalDAV-Sender"] + assert ( + organizer.email in sender or f"mailto:{organizer.email}" in sender + ), f"Expected sender to be {organizer.email}, got {sender}" + + # Verify recipient is the attendee + recipient = request_data["headers"]["X-CalDAV-Recipient"] + assert ( + external_attendee in recipient + or f"mailto:{external_attendee}" in recipient + ), f"Expected recipient to be {external_attendee}, got {recipient}" + + # Verify method is REQUEST (for new invitations) + method = request_data["headers"]["X-CalDAV-Method"] + assert method == "REQUEST", ( + f"Expected method to be REQUEST for new invitation, got {method}" + ) + + # Verify iCalendar content is present + assert request_data["body"], "Callback request body is empty" + assert "BEGIN:VCALENDAR" in request_data["body"], ( + "Callback body should contain iCalendar content" + ) + assert "VEVENT" in request_data["body"], ( + "Callback body should contain VEVENT" + ) + + # Normalize iCalendar body to handle line folding (CRLF + space/tab) + # iCalendar format folds long lines at 75 characters, so we need to remove folding + # Line folding: CRLF followed by space or tab indicates continuation + body = request_data["body"] + # Remove line folding: replace CRLF+space and CRLF+tab with nothing + normalized_body = body.replace("\r\n ", "").replace("\r\n\t", "") + # Also handle Unix-style line endings + normalized_body = normalized_body.replace("\n ", "").replace("\n\t", "") + assert external_attendee in normalized_body, ( + f"Callback body should contain attendee email {external_attendee}. " + f"Normalized body (first 500 chars): {normalized_body[:500]}" + ) + + except NotFoundError: + pytest.skip("Calendar not found - CalDAV server may not be running") + except Exception as e: + pytest.fail(f"Failed to create event with attendee: {str(e)}") + finally: + # Shutdown server + server.shutdown() + server.server_close() diff --git a/src/backend/core/tests/test_caldav_service.py b/src/backend/core/tests/test_caldav_service.py index 1459902..008d182 100644 --- a/src/backend/core/tests/test_caldav_service.py +++ b/src/backend/core/tests/test_caldav_service.py @@ -22,9 +22,9 @@ class TestCalDAVClient: dav_client = client._get_client(user) # Verify the client is configured correctly - assert dav_client.username == user.email - # Password should be empty (None or empty string) for external auth - assert not dav_client.password or dav_client.password == "" + # Username and password should be None to prevent Basic auth + assert dav_client.username is None + assert dav_client.password is None # Verify the X-Forwarded-User header is set # The caldav library stores headers as a CaseInsensitiveDict diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 9d17eed..a3aef8d 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -7,7 +7,7 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls from rest_framework.routers import DefaultRouter from core.api import viewsets -from core.api.viewsets_caldav import CalDAVProxyView +from core.api.viewsets_caldav import CalDAVProxyView, CalDAVSchedulingCallbackView from core.external_api import viewsets as external_api_viewsets # - Main endpoints @@ -31,6 +31,12 @@ urlpatterns = [ CalDAVProxyView.as_view(), name="caldav-proxy", ), + # CalDAV scheduling callback endpoint (separate from caldav proxy) + path( + "caldav-scheduling-callback/", + CalDAVSchedulingCallbackView.as_view(), + name="caldav-scheduling-callback", + ), ] ), ),