(scheduling) add callback from caldav to django for imip

This commit is contained in:
Sylvain Zimmer
2026-01-11 03:52:43 +01:00
parent bc801d3007
commit 3ed52ca5d0
15 changed files with 636 additions and 41 deletions

View File

@@ -3,6 +3,7 @@
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
--name backend-test \
-e DJANGO_CONFIGURATION=Test \
backend-dev \
pytest "$@"

View File

@@ -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

View File

@@ -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

View File

@@ -8,12 +8,6 @@
Options -Indexes +FollowSymLinks
</Directory>
# Set REMOTE_USER from X-Forwarded-User header (set by Django proxy)
# This allows sabre/dav to use Apache auth backend
<IfModule mod_headers.c>
RequestHeader set REMOTE_USER %{HTTP:X-Forwarded-User}e env=HTTP_X_FORWARDED_USER
</IfModule>
# 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
</VirtualHost>

View File

@@ -1,7 +1,7 @@
<?php
/**
* sabre/dav CalDAV Server
* Configured to use PostgreSQL backend and Apache authentication
* Configured to use PostgreSQL backend and custom header-based authentication
*/
use Sabre\DAV\Auth;
@@ -10,16 +10,12 @@ use Sabre\CalDAV;
use Sabre\CardDAV;
use Sabre\DAV;
use Calendars\SabreDav\AutoCreatePrincipalBackend;
use Calendars\SabreDav\HttpCallbackIMipPlugin;
use Calendars\SabreDav\ApiKeyAuthBackend;
// Composer autoloader
require_once __DIR__ . '/vendor/autoload.php';
// Set REMOTE_USER from X-Forwarded-User header (set by Django proxy)
// This allows sabre/dav Apache auth backend to work with proxied requests
if (isset($_SERVER['HTTP_X_FORWARDED_USER']) && !isset($_SERVER['REMOTE_USER'])) {
$_SERVER['REMOTE_USER'] = $_SERVER['HTTP_X_FORWARDED_USER'];
}
// Get base URI from environment variable (set by compose.yaml)
// This ensures sabre/dav generates URLs with the correct proxy path
$baseUri = getenv('CALENDARS_BASE_URI') ?: '/';
@@ -42,8 +38,14 @@ $pdo = new PDO(
]
);
// Create backend
$authBackend = new Auth\Backend\Apache();
// Create custom authentication backend
// Requires API key authentication and X-Forwarded-User header
$apiKey = getenv('CALDAV_OUTBOUND_API_KEY');
if (!$apiKey) {
error_log("[sabre/dav] CALDAV_OUTBOUND_API_KEY environment variable is required");
exit(1);
}
$authBackend = new ApiKeyAuthBackend($apiKey);
// Create authentication plugin
$authPlugin = new Auth\Plugin($authBackend);
@@ -75,5 +77,25 @@ $server->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();

View File

@@ -0,0 +1,86 @@
<?php
/**
* Custom authentication backend that supports API key and header-based authentication.
*
* This backend authenticates users via:
* - API key authentication: X-Api-Key header and X-Forwarded-User header
*
* This allows Django to authenticate with CalDAV server using an API key.
*/
namespace Calendars\SabreDav;
use Sabre\DAV\Auth\Backend\BackendInterface;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
class ApiKeyAuthBackend implements BackendInterface
{
/**
* Expected API key for outbound authentication (from Django to CalDAV)
* @var string
*/
private $apiKey;
/**
* Constructor
*
* @param string $apiKey The expected API key for authentication
*/
public function __construct($apiKey)
{
$this->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
}
}

View File

@@ -0,0 +1,151 @@
<?php
/**
* Custom IMipPlugin that forwards scheduling messages via HTTP callback instead of sending emails.
*
* This plugin extends sabre/dav's IMipPlugin but instead of sending emails via PHP's mail()
* function, it forwards the scheduling messages to an HTTP callback endpoint secured by API key.
*
* @see https://sabre.io/dav/scheduling/
*/
namespace Calendars\SabreDav;
use Sabre\CalDAV\Schedule\IMipPlugin;
use Sabre\DAV\Server;
use Sabre\VObject\ITip\Message;
class HttpCallbackIMipPlugin extends IMipPlugin
{
/**
* API key for authenticating with the callback endpoint
* @var string
*/
private $apiKey;
/**
* Reference to the DAV server instance
* @var Server
*/
private $server;
/**
* Constructor
*
* @param string $apiKey The API key for authenticating with the callback endpoint
*/
public function __construct($apiKey)
{
// Call parent constructor with empty email (we won't use it)
parent::__construct('');
$this->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';
}
}

View File

@@ -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

View File

@@ -3,4 +3,6 @@ PGPORT=5432
PGDATABASE=calendars
PGUSER=pgroot
PGPASSWORD=pass
CALENDARS_BASE_URI=/api/v1.0/caldav/
CALENDARS_BASE_URI=/api/v1.0/caldav/
CALDAV_INBOUND_API_KEY=changeme-inbound-in-production
CALDAV_OUTBOUND_API_KEY=changeme-outbound-in-production

View File

@@ -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)

View File

@@ -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")

View File

@@ -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:

View File

@@ -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()

View File

@@ -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

View File

@@ -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",
),
]
),
),